diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift index 30356f083..3367c28ca 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift @@ -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) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift index 570072b41..f2896030a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift @@ -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") diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 26532feda..fcf3d0f37 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index 92eec8ce0..200093972 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift index 20d737d03..acb9ad83b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift @@ -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: { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift index 6aaa82bcf..ab2cea59d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift @@ -78,6 +78,7 @@ struct WebReaderContent { readingProgressAnchorIndex: \(item.readingProgressAnchor), labels: \(item.labelsJSONString), highlights: \(articleContent.highlightsJSONString), + recommendations: \(item.recommendationsJSONString), } window.fontSize = \(textFontSize) diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index deb3afd4f..4ca8869bd 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -55,7 +55,7 @@ - + @@ -95,8 +95,9 @@ + - + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 0459d5b59..529d3204e 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -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 = "" diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift index 279a74268..dc2006c33 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift @@ -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 + } + } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift index 51ded19f7..8e7be6b56 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -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) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift index 80a33b0cc..e861acf30 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift @@ -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 ) diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift index ff90cc253..dbfdfdffd 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift @@ -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 { diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index ed93b0045..3e7e7eb1a 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -23,4 +23,5 @@ public enum UserDefaultKey: String { case recentSearchTerms case audioPlayerExpanded case themeName + case shouldShowNewFeaturePrimer } diff --git a/apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift b/apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift new file mode 100644 index 000000000..e67d35591 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift @@ -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) + + """ + ) + } +} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 027bc4427..513842b49 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift b/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift index 4bab1d0d8..56d26bd3d 100644 --- a/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift +++ b/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift @@ -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 diff --git a/packages/web/components/elements/StyledText.tsx b/packages/web/components/elements/StyledText.tsx index 1f7b3a4f3..618bb30df 100644 --- a/packages/web/components/elements/StyledText.tsx +++ b/packages/web/components/elements/StyledText.tsx @@ -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': { diff --git a/packages/web/components/templates/article/ArticleContainer.tsx b/packages/web/components/templates/article/ArticleContainer.tsx index 4ee3a3285..aab1d0da1 100644 --- a/packages/web/components/templates/article/ArticleContainer.tsx +++ b/packages/web/components/templates/article/ArticleContainer.tsx @@ -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(null) - const [lineHeightOverride, setLineHeightOverride] = - useState(null) - const [fontFamilyOverride, setFontFamilyOverride] = - useState(null) + const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] = useState< + number | null + >(null) + const [lineHeightOverride, setLineHeightOverride] = useState( + null + ) + const [fontFamilyOverride, setFontFamilyOverride] = useState( + 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 ( <> ) : null} + {recommendationByline && ( + + + + + Recommended by {recommendationByline} + + + + {recommendationsWithNotes.map((item, idx) => ( + + {/* {item.note} */} + + + + {item.user?.name}: + {' '} + {item.note} + + : + + + ))} + + )}