Clean up iOS recommendations, show a primer on first load

This commit is contained in:
Jackson Harper 2022-12-07 19:06:50 +08:00
parent 965d7286a5
commit 55e925cc98
21 changed files with 467 additions and 139 deletions

View file

@ -5,7 +5,7 @@ import Views
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = true
@Published var networkError = false
@Published var recommendationGroup: InternalRecommendationGroup
init(recommendationGroup: InternalRecommendationGroup) {
@ -71,8 +71,6 @@ struct RecommendationGroupView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel: RecommendationsGroupViewModel
@State var presentShareSheet = false
var body: some View {
Group {
#if os(iOS)
@ -110,12 +108,22 @@ struct RecommendationGroupView: View {
private var membersSection: some View {
Section("Members") {
ForEach(viewModel.nonAdmins) { member in
SmallUserCard(data: ProfileCardData(
name: member.name,
username: member.username,
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
))
if viewModel.nonAdmins.count > 0 {
ForEach(viewModel.nonAdmins) { member in
SmallUserCard(data: ProfileCardData(
name: member.name,
username: member.username,
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
))
}
} else {
Text("""
This group does not have any members. Add users to your group by sending
them the invite link.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
}
@ -128,7 +136,17 @@ struct RecommendationGroupView: View {
Section("Invite Link") {
Button(action: {
presentShareSheet = true
#if os(iOS)
UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
Snackbar.show(message: "Invite link copied")
}, label: {
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
.font(.appCaption)
@ -138,9 +156,6 @@ struct RecommendationGroupView: View {
adminsSection
membersSection
}
.formSheet(isPresented: $presentShareSheet) {
shareView
}
.navigationTitle(viewModel.recommendationGroup.name)
}
}

View file

@ -6,7 +6,7 @@ import Views
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
@Published var isLoading = false
@Published var isCreating = false
@Published var networkError = true
@Published var networkError = false
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var showCreateSheet = false
@ -130,14 +130,29 @@ struct GroupsView: View {
.disabled(viewModel.isLoading)
}
Section(header: Text("Your recommendation groups")) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
NavigationLink(
destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup))
) {
Text(recommendationGroup.name)
if viewModel.recommendationGroups.count > 0 {
Section(header: Text("Your recommendation groups")) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
NavigationLink(
destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup))
) {
Text(recommendationGroup.name)
}
}
}
} else {
Section {
Text("""
You are not a member of any groups.
Create a new group and send the invite link to your friends get started.
During the beta you are limited to creating three groups, and each group
can have a maximum of twelve users.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
}
.navigationTitle("Recommendation Groups")

View file

@ -47,30 +47,35 @@ struct InnerRootView: View {
@ViewBuilder private var innerBody: some View {
if authenticator.isLoggedIn {
PrimaryContentView()
.onAppear {
viewModel.triggerPushNotificationRequestIfNeeded()
}
#if os(iOS)
.miniPlayer()
#endif
.snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage)
// Schedule the dismissal every time we present the snackbar.
.onChange(of: viewModel.showSnackbar) { newValue in
if newValue {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
viewModel.showSnackbar = false
GeometryReader { geo in
PrimaryContentView()
.onAppear {
viewModel.triggerPushNotificationRequestIfNeeded()
}
#if os(iOS)
.miniPlayer()
.formSheet(isPresented: $viewModel.showNewFeaturePrimer, modalSize: CGSize(width: geo.size.width * 0.66, height: geo.size.width * 0.66)) {
FeaturePrimer.recommendationsPrimer
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
viewModel.showNewFeaturePrimer = viewModel.shouldShowNewFeaturePrimer
viewModel.shouldShowNewFeaturePrimer = false
}
}
#endif
.snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage)
// Schedule the dismissal every time we present the snackbar.
.onChange(of: viewModel.showSnackbar) { newValue in
if newValue {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
viewModel.showSnackbar = false
}
}
}
}
}
#if os(iOS)
.customAlert(isPresented: $viewModel.showPushNotificationPrimer) {
pushNotificationPrimerView
}
#endif
}
} else {
WelcomeView()
.accessibilityElement()
@ -105,14 +110,14 @@ struct InnerRootView: View {
}
#if os(iOS)
private var pushNotificationPrimerView: PushNotificationPrimer {
PushNotificationPrimer(
acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() },
denyAction: {
UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue)
viewModel.showPushNotificationPrimer = false
}
)
}
// private var pushNotificationPrimerView: PushNotificationPrimer {
// PushNotificationPrimer(
// acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() },
// denyAction: {
// UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue)
// viewModel.showPushNotificationPrimer = false
// }
// )
// }
#endif
}

View file

@ -14,7 +14,9 @@ import Views
public final class RootViewModel: ObservableObject {
let services = Services()
@Published public var showPushNotificationPrimer = false
@Published public var showNewFeaturePrimer = false
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = true
@Published var snackbarMessage: String?
@Published var showSnackbar = false
@Published var showMiniPlayer = true
@ -34,32 +36,32 @@ public final class RootViewModel: ObservableObject {
}
func triggerPushNotificationRequestIfNeeded() {
guard FeatureFlag.enablePushNotifications else { return }
if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) {
return
}
#if os(iOS)
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
switch settings.authorizationStatus {
case .notDetermined:
DispatchQueue.main.async {
self?.showPushNotificationPrimer = true
}
case .authorized, .provisional, .ephemeral, .denied:
return
@unknown default:
return
}
}
#endif
// guard FeatureFlag.enablePushNotifications else { return }
//
// if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) {
// return
// }
//
// #if os(iOS)
// UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
// switch settings.authorizationStatus {
// case .notDetermined:
// DispatchQueue.main.async {
// self?.showPushNotificationPrimer = true
// }
// case .authorized, .provisional, .ephemeral, .denied:
// return
// @unknown default:
// return
// }
// }
// #endif
}
#if os(iOS)
func handlePushNotificationPrimerAcceptance() {
showPushNotificationPrimer = false
UNUserNotificationCenter.current().requestAuth()
// showPushNotificationPrimer = false
// UNUserNotificationCenter.current().requestAuth()
}
#endif
}

View file

@ -5,12 +5,13 @@ import Views
@MainActor final class RecommendToViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = true
@Published var networkError = false
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var selectedGroups = [String]()
@Published var selectedGroups = [InternalRecommendationGroup]()
@Published var isRunning = false
@Published var showError = false
@Published var note: String?
@Published var showNoteView = false
@Published var note: String = ""
let pageID: String
@ -35,7 +36,7 @@ import Views
isRunning = true
do {
try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups, note: note)
try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups.map(\.id), note: note.isEmpty ? nil : note)
} catch {
showError = true
}
@ -50,6 +51,16 @@ struct RecommendToView: View {
@Environment(\.dismiss) private var dismiss
var nextButton: some View {
Button(action: {
self.viewModel.showNoteView = true
}, label: {
Text("Next")
.bold()
})
.disabled(viewModel.selectedGroups.isEmpty)
}
var sendButton: some View {
if viewModel.isRunning {
return AnyView(ProgressView())
} else {
@ -60,15 +71,60 @@ struct RecommendToView: View {
dismiss()
}
}, label: {
Text("Next")
Text("Send")
.bold()
})
.disabled(viewModel.selectedGroups.isEmpty)
)
}
}
var noteView: some View {
VStack {
HStack {
Text("To:")
.font(.appCaption)
.foregroundColor(.appGrayText)
Text(InternalRecommendationGroup.readable(list: viewModel.selectedGroups))
.font(.appCaption)
.foregroundColor(.appGrayTextContrast)
Spacer()
}
TextEditor(text: $viewModel.note)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayTextContrast)
.font(.appBody)
.padding(12)
.frame(height: 200)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
)
.overlay(
Text("Add a note (optional)")
.allowsHitTesting(false)
.opacity(viewModel.note.isEmpty ? 0.4 : 0.0)
.font(.appBody)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 24)
.padding(.leading, 16)
)
Spacer()
}
.padding(16)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(trailing: sendButton)
}
var body: some View {
VStack {
NavigationLink(destination: noteView,
isActive: $viewModel.showNoteView) {
EmptyView()
}
List {
Section("Select groups to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
@ -77,17 +133,17 @@ struct RecommendToView: View {
Spacer()
if viewModel.selectedGroups.contains(group.id) {
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(of: group.id)
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group.id)
viewModel.selectedGroups.append(group)
}
}
}
@ -105,7 +161,6 @@ struct RecommendToView: View {
}
)
}
.navigationBarTitle("Recommend")
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(leading: Button(action: {

View file

@ -78,6 +78,7 @@ struct WebReaderContent {
readingProgressAnchorIndex: \(item.readingProgressAnchor),
labels: \(item.labelsJSONString),
highlights: \(articleContent.highlightsJSONString),
recommendations: \(item.recommendationsJSONString),
}
window.fontSize = \(textFontSize)

View file

@ -55,7 +55,7 @@
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkedItem" inverseEntity="Recommendation"/>
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkeditem" inverseEntity="Recommendation"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
@ -95,8 +95,9 @@
<entity name="Recommendation" representedClassName="Recommendation" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="recommendedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendations" inverseEntity="LinkedItem"/>
<relationship name="linkeditem" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendations" inverseEntity="LinkedItem"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
</entity>
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">

View file

@ -129,6 +129,26 @@ public extension LinkedItem {
return String(data: JSON, encoding: .utf8) ?? "[]"
}
var recommendationsJSONString: String {
let recommendations = self.recommendations.asArray(of: Recommendation.self).map { recommendation in
let recommendedAt = recommendation.recommendedAt == nil ? nil : recommendation.recommendedAt?.ISO8601Format()
return [
"id": NSString(string: recommendation.id ?? ""),
"name": NSString(string: recommendation.name ?? ""),
"note": recommendation.note == nil ? nil : NSString(string: recommendation.note ?? ""),
"user": recommendation.user == nil ? nil : NSDictionary(dictionary: [
"userID": NSString(string: recommendation.user?.userID ?? ""),
"name": NSString(string: recommendation.user?.name ?? ""),
"username": NSString(string: recommendation.user?.username ?? ""),
"profileImageURL": recommendation.user?.profileImageURL == nil ? nil : NSString(string: recommendation.user?.profileImageURL ?? "")
]),
"recommendedAt": recommendedAt == nil ? nil : NSString(string: recommendedAt!)
]
}
guard let JSON = (try? JSONSerialization.data(withJSONObject: recommendations, options: .prettyPrinted)) else { return "[]" }
return String(data: JSON, encoding: .utf8) ?? "[]"
}
var formattedByline: String {
var byline = ""

View file

@ -18,4 +18,30 @@ public extension Recommendation {
return recommendation
}
static func byline(_ set: NSSet) -> String {
Array(set).reduce("") { str, item in
if let recommendation = item as? Recommendation, let userName = recommendation.user?.name {
if str.isEmpty {
return userName
} else {
return str + ", " + userName
}
}
return str
}
}
static func groupsLine(_ set: NSSet) -> String {
Array(set).reduce("") { str, item in
if let recommendation = item as? Recommendation, let name = recommendation.name {
if str.isEmpty {
return name
} else {
return str + ", " + name
}
}
return str
}
}
}

View file

@ -225,37 +225,22 @@ extension DataService {
}
let recommendingUserSelection = Selection.RecommendingUser {
do {
return InternalUserProfile(
userID: try $0.userId(),
name: try $0.name(),
username: try $0.username(),
profileImageURL: nil // try $0.profileImageUrl() ?? nil
)
} catch {
print("ERROR WITH recommendingUserSelection", error)
throw error
}
InternalUserProfile(
userID: try $0.userId(),
name: try $0.name(),
username: try $0.username(),
profileImageURL: nil // try $0.profileImageUrl() ?? nil
)
}
let recommendationSelection = Selection.Recommendation {
do {
let result = InternalRecommendation(
id: try $0.id(),
name: try $0.name(),
user: try $0.user(selection: recommendingUserSelection.nullable),
recommendedAt: try $0.recommendedAt().value ?? Date()
)
return result
} catch {
print("ERROR WITH recommendationSelection", error)
throw error
}
}
private func emptyrecommended() -> [InternalRecommendation] {
print("got the empty InternalRecommendation")
return []
InternalRecommendation(
id: try $0.id(),
name: try $0.name(),
note: try $0.note(),
user: try $0.user(selection: recommendingUserSelection.nullable),
recommendedAt: try $0.recommendedAt().value ?? Date()
)
}
private let libraryArticleSelection = Selection.Article {
@ -283,7 +268,7 @@ private let libraryArticleSelection = Selection.Article {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? emptyrecommended(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}

View file

@ -5,6 +5,7 @@ import Models
public struct InternalRecommendation {
let id: String
let name: String
let note: String?
let user: InternalUserProfile?
let recommendedAt: Date
@ -13,6 +14,7 @@ public struct InternalRecommendation {
let recommendation = existing ?? Recommendation(entity: Recommendation.entity(), insertInto: context)
recommendation.id = id
recommendation.name = name
recommendation.note = note
recommendation.recommendedAt = recommendedAt
recommendation.user = user?.asManagedObject(inContext: context)
return recommendation
@ -29,6 +31,7 @@ public struct InternalRecommendation {
return InternalRecommendation(
id: id,
name: name,
note: recommendation.note,
user: InternalUserProfile.makeSingle(recommendation.user),
recommendedAt: recommendedAt
)

View file

@ -46,6 +46,16 @@ public struct InternalRecommendationGroup: Identifiable {
}
return nil
}
public static func readable(list: [InternalRecommendationGroup]) -> String {
list.reduce("") { str, group in
if str.isEmpty {
return group.name
} else {
return str + ", " + group.name
}
}
}
}
extension Sequence where Element == InternalRecommendationGroup {

View file

@ -23,4 +23,5 @@ public enum UserDefaultKey: String {
case recentSearchTerms
case audioPlayerExpanded
case themeName
case shouldShowNewFeaturePrimer
}

View file

@ -0,0 +1,69 @@
//
// File.swift
//
//
// Created by Jackson Harper on 12/7/22.
//
import Foundation
import SwiftUI
public struct FeaturePrimer: View {
let isBeta: Bool
let title: String
let message: String
@Environment(\.dismiss) private var dismiss
public var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading) {
Text(title)
.font(.textToSpeechRead)
.foregroundColor(Color.appGrayTextContrast)
.frame(maxWidth: .infinity, alignment: .leading)
if isBeta {
HStack {
TextChip(text: "¡ Beta !", color: Color.red)
.frame(alignment: .leading)
TextChip(text: "¡ New Feature !", color: Color.green)
.frame(alignment: .leading)
}
}
}
ScrollView {
Text((try? AttributedString(markdown: message,
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? "")
.foregroundColor(Color.appGrayText)
.accentColor(.blue)
.padding(.bottom, 16)
}
.padding(.top, 16)
Spacer()
Button(action: {
dismiss()
}, label: { Text("Dismiss") })
.buttonStyle(PlainButtonStyle())
}.padding()
}
public static var recommendationsPrimer: some View {
FeaturePrimer(
isBeta: true,
title: "Introducing Recommendation Groups",
message: """
Recommendation groups make it easy to share great reads with friends and co-workers.
To get started, create a Recommendation Group from the profile page and invite some friends.
*During the beta you can create a max of three groups. Group sizes are limited to 12 people.*
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
"""
)
}
}

View file

@ -88,19 +88,10 @@ public struct FeedCard: View {
}
if let recommendations = item.recommendations, recommendations.count > 0 {
let byStr = recommendations.reduce("") { str, item in
if let item = item as? Recommendation, let name = item.user?.name {
return str + name
}
return str
}
let inStr = recommendations.reduce("") { str, item in
if let item = item as? Recommendation, let name = item.name {
return str + name
}
return str
}
let byStr = Recommendation.byline(recommendations)
let inStr = Recommendation.groupsLine(recommendations)
HStack {
Image(systemName: "sparkles")
Text("Recommended by \(byStr) in \(inStr)")
.font(.appCaption)
.frame(alignment: .leading)

View file

@ -49,7 +49,7 @@ import SwiftUI
controller.view.sizeToFit()
controller.modalPresentationStyle = .formSheet
controller.modalTransitionStyle = .crossDissolve
controller.preferredContentSize = CGSize(width: 320, height: 320)
controller.preferredContentSize = modalSize
}
controller.presentationController?.delegate = self

View file

@ -22,6 +22,28 @@ const textVariants = {
fontSize: '$2',
lineHeight: '1.25',
},
recommendedByline: {
// fontWeight: 'bold',
fontSize: '13.5px',
paddingTop: '4px',
mt: '0px',
mb: '24px',
color: '$grayText',
},
userName: {
fontWeight: '600',
fontSize: '13.5px',
paddingTop: '4px',
my: '6px',
color: '$grayText',
},
userNote: {
fontSize: '16px',
paddingTop: '0px',
marginTop: '0px',
lineHeight: '1.5',
color: '$grayTextContrast',
},
headline: {
fontSize: '$4',
'@md': {

View file

@ -1,12 +1,25 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { Article } from './../../../components/templates/article/Article'
import { Box, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import {
Blockquote,
Box,
HStack,
SpanBox,
VStack,
} from './../../elements/LayoutPrimitives'
import { StyledText } from './../../elements/StyledText'
import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
import { theme, ThemeId } from './../../tokens/stitches.config'
import { styled, theme, ThemeId } from './../../tokens/stitches.config'
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
import { Button } from '../../elements/Button'
import { MutableRefObject, useEffect, useState, useRef } from 'react'
import {
MutableRefObject,
useEffect,
useState,
useRef,
useReducer,
useMemo,
} from 'react'
import { ReportIssuesModal } from './ReportIssuesModal'
import { reportIssueMutation } from '../../../lib/networking/mutations/reportIssueMutation'
import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
@ -19,6 +32,9 @@ import {
HighlightLocation,
makeHighlightStartEndOffset,
} from '../../../lib/highlights/highlightGenerator'
import { Recommendation } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { Avatar } from '../../elements/Avatar'
import { Sparkle } from 'phosphor-react'
type ArticleContainerProps = {
article: ArticleAttributes
@ -41,12 +57,15 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
// iOS app embed can overide the original margin and line height
const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] =
useState<number | null>(null)
const [lineHeightOverride, setLineHeightOverride] =
useState<number | null>(null)
const [fontFamilyOverride, setFontFamilyOverride] =
useState<string | null>(null)
const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] = useState<
number | null
>(null)
const [lineHeightOverride, setLineHeightOverride] = useState<number | null>(
null
)
const [fontFamilyOverride, setFontFamilyOverride] = useState<string | null>(
null
)
const [highContrastFont, setHighContrastFont] = useState(
props.highContrastFont ?? false
)
@ -196,6 +215,31 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
readerHeadersColor: theme.colors.readerHeader.toString(),
}
const recommendationByline = useMemo(() => {
return props.article.recommendations
?.flatMap((recommendation) => {
return recommendation.user?.name
})
.join(', ')
}, [props.article.recommendations])
const recommendationsWithNotes = useMemo(() => {
return (
props.article.recommendations?.filter((recommendation) => {
return recommendation.note
}) ?? []
)
}, [props.article.recommendations])
const StyledQuote = styled(Blockquote, {
margin: '0px 0px 0px 0px',
fontSize: '18px',
lineHeight: '27px',
color: '$grayText',
padding: '0px 16px',
borderLeft: '2px solid $omnivoreCtaYellow',
})
return (
<>
<Box
@ -266,6 +310,45 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
))}
</SpanBox>
) : null}
{recommendationByline && (
<VStack
css={{
borderRadius: '6px',
bg: '$grayBase',
p: '16px',
pt: '16px',
width: '100%',
marginTop: '24px',
color: '$grayText',
lineHeight: '2.0',
}}
>
<HStack css={{ gap: '8px' }}>
<Sparkle size="14" />
<StyledText
style="recommendedByline"
css={{ paddingTop: '0px' }}
>
Recommended by {recommendationByline}
</StyledText>
</HStack>
{recommendationsWithNotes.map((item, idx) => (
<VStack key={item.id} alignment="start" distribution="start">
{/* <StyledQuote>{item.note}</StyledQuote> */}
<HStack css={{}} alignment="start">
<StyledText style="userNote">
<SpanBox css={{ opacity: '0.5' }}>
{item.user?.name}:
</SpanBox>{' '}
{item.note}
</StyledText>
:
</HStack>
</VStack>
))}
</VStack>
)}
</VStack>
<Article
articleId={props.article.id}

View file

@ -7,7 +7,7 @@ export type LabelColor =
| '#7BE4FF'
| '#CE88EF'
| '#EF8C43'
| 'custom color';
| 'custom color'
export const labelFragment = gql`
fragment LabelFields on Label {
@ -25,4 +25,4 @@ export type Label = {
color: LabelColor
description?: string
createdAt: Date
}
}

View file

@ -9,7 +9,11 @@ import {
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { ScopedMutator } from 'swr/dist/types'
import { Label, labelFragment } from '../fragments/labelFragment'
import { LibraryItems } from './useGetLibraryItemsQuery'
import {
LibraryItems,
Recommendation,
recommendationFragment,
} from './useGetLibraryItemsQuery'
type ArticleQueryInput = {
username?: string
@ -54,6 +58,7 @@ export type ArticleAttributes = {
linkId: string
labels?: Label[]
state?: State
recommendations?: Recommendation[]
}
const query = gql`
@ -73,6 +78,9 @@ const query = gql`
labels {
...LabelFields
}
recommendations {
...RecommendationFields
}
}
}
... on ArticleError {
@ -83,6 +91,7 @@ const query = gql`
${articleFragment}
${highlightFragment}
${labelFragment}
${recommendationFragment}
`
export function useGetArticleQuery({
@ -115,7 +124,7 @@ export function useGetArticleQuery({
return {
articleData: resultData,
isLoading: !error && !data,
articleFetchError: resultError ? resultError as string[] : null,
articleFetchError: resultError ? (resultError as string[]) : null,
}
}

View file

@ -108,6 +108,21 @@ export type PageInfo = {
totalCount: number
}
export const recommendationFragment = gql`
fragment RecommendationFields on Recommendation {
id
name
note
user {
userId
name
username
profileImageURL
}
recommendedAt
}
`
export function useGetLibraryItemsQuery({
limit,
sortDescending,