Merge pull request #1522 from omnivore-app/feat/ios-recommendations

More on iOS recommendations
This commit is contained in:
Jackson Harper 2022-12-20 17:24:47 +08:00 committed by GitHub
commit fa659bdf66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1840 additions and 318 deletions

View file

@ -2,31 +2,31 @@ import SwiftUI
import Views
// TODO: maybe move this into Views package?
struct IconButtonView: View {
let title: String
let systemIconName: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(alignment: .center, spacing: 8) {
Image(systemName: systemIconName)
.font(.appTitle)
.foregroundColor(.appYellow48)
Text(title)
.font(.appBody)
.foregroundColor(.appGrayText)
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity
)
.background(Color.appButtonBackground)
.cornerRadius(8)
}
.frame(height: 100)
}
}
// struct IconButtonView: View {
// let title: String
// let systemIconName: String
// let action: () -> Void
//
// var body: some View {
// Button(action: action) {
// VStack(alignment: .center, spacing: 8) {
// Image(systemName: systemIconName)
// .font(.appTitle)
// .foregroundColor(.appYellow48)
// Text(title)
// .font(.appBody)
// .foregroundColor(.appGrayText)
// }
// .frame(
// maxWidth: .infinity,
// maxHeight: .infinity
// )
// .background(Color.appButtonBackground)
// .cornerRadius(8)
// }
// .frame(height: 100)
// }
// }
struct CheckmarkButtonView: View {
let titleText: String

View file

@ -107,10 +107,37 @@ struct HighlightsListCard: View {
}
.padding(.top, 16)
if let createdBy = highlightParams.createdBy {
HStack(alignment: .center) {
if let profileImageURL = createdBy.profileImageURL, let url = URL(string: profileImageURL) {
AsyncImage(
url: url,
content: { $0.resizable() },
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayText)
}
)
.aspectRatio(contentMode: .fill)
.frame(width: 14, height: 14, alignment: .center)
.clipShape(Circle())
} else {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayText)
.frame(width: 14, height: 14)
}
Text("Highlight by \(highlightParams.createdBy?.name ?? "you")")
.font(.appFootnote)
.foregroundColor(.appGrayText)
}
}
HStack {
Divider()
.frame(width: 2)
.overlay(Color.appYellow48)
.overlay(highlightParams.createdBy != nil ? Color(red: 206 / 255.0, green: 239 / 255.0, blue: 159 / 255.0) : Color.appYellow48)
.opacity(0.8)
.padding(.top, 2)
.padding(.trailing, 6)

View file

@ -11,6 +11,7 @@ struct HighlightListItemParams: Identifiable {
let annotation: String
let quote: String
let labels: [LinkedItemLabel]
let createdBy: InternalUserProfile?
}
@MainActor final class HighlightsListViewModel: ObservableObject {
@ -31,7 +32,8 @@ struct HighlightListItemParams: Identifiable {
title: highlightItems[index].title,
annotation: annotation,
quote: highlightItems[index].quote,
labels: highlightItems[index].labels
labels: highlightItems[index].labels,
createdBy: highlightItems[index].createdBy
)
}
}
@ -50,7 +52,8 @@ struct HighlightListItemParams: Identifiable {
title: highlightItems[index].title,
annotation: highlightItems[index].annotation,
quote: highlightItems[index].quote,
labels: labels
labels: labels,
createdBy: highlightItems[index].createdBy
)
}
}
@ -68,7 +71,8 @@ struct HighlightListItemParams: Identifiable {
title: "Highlight",
annotation: $0.annotation ?? "",
quote: $0.quote ?? "",
labels: $0.labels.asArray(of: LinkedItemLabel.self)
labels: $0.labels.asArray(of: LinkedItemLabel.self),
createdBy: $0.createdByMe ? nil : InternalUserProfile.makeSingle($0.createdBy)
)
}
}

View file

@ -28,7 +28,7 @@ struct MacFeedCardNavigationLink: View {
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
}
FeedCard(item: item) {
FeedCard(item: item, viewer: dataService.currentViewer) {
viewModel.selectedLinkItem = item.objectID
}
}
@ -58,7 +58,7 @@ struct FeedCardNavigationLink: View {
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
}
FeedCard(item: item)
FeedCard(item: item, viewer: dataService.currentViewer)
}
}
}

View file

@ -76,6 +76,26 @@ struct ProfileView: View {
#endif
}
private var accountSection: some View {
Section {
NavigationLink(destination: LabelsView()) {
Text("Labels")
}
NavigationLink(destination: NewsletterEmailsView()) {
Text("Emails")
}
NavigationLink(destination: SubscriptionsView()) {
Text("Subscriptions")
}
NavigationLink(destination: GroupsView()) {
Text("Recommendation Groups")
}
}
}
private var innerBody: some View {
Group {
Section {
@ -85,26 +105,13 @@ struct ProfileView: View {
}
}
Section {
NavigationLink(destination: LabelsView()) {
Text("Labels")
}
NavigationLink(destination: NewsletterEmailsView()) {
Text("Emails")
}
NavigationLink(destination: SubscriptionsView()) {
Text("Subscriptions")
}
NavigationLink(destination: GroupsView()) {
Text("Recommendation Groups")
}
}
accountSection
#if os(iOS)
Section {
NavigationLink(destination: PushNotificationSettingsView()) {
Text("Push Notifications")
}
NavigationLink(destination: TextToSpeechView()) {
Text("Text to Speech")
}

View file

@ -0,0 +1,82 @@
import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor final class PushNotificationDevicesViewModel: ObservableObject {
@Published var isLoading = false
@Published var devices = [InternalDeviceToken]()
func loadDevices(dataService: DataService) {
isLoading = true
Task {
self.devices = (try? await dataService.devices()) ?? []
isLoading = false
}
}
func removeToken(dataService: DataService, tokenID: String) {
if let idx = devices.firstIndex(where: { $0.id == tokenID }) {
Task {
try await dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
devices.remove(at: idx)
}
}
}
}
struct PushNotificationDevicesView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = PushNotificationDevicesViewModel()
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.task { viewModel.loadDevices(dataService: dataService) }
}
func createdStr(_ device: InternalDeviceToken) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
if let createdAt = device.createdAt {
return dateFormatter.string(from: createdAt)
}
return ""
}
private var innerBody: some View {
List {
Section(header: Text("Registered device tokens (swipe to remove)")) {
ForEach(viewModel.devices) { device in
Text("Created: \(createdStr(device))")
.swipeActions(edge: .trailing) {
Button(
role: .destructive,
action: {
viewModel.removeToken(dataService: dataService, tokenID: device.id)
},
label: {
Image(systemName: "trash")
}
)
}
}
}
}
.navigationTitle("Devices")
}
}

View file

@ -0,0 +1,96 @@
import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor final class PushNotificationSettingsViewModel: ObservableObject {
@Published var isLoading = false
@Published var emails = [NewsletterEmail]()
@Published var desiredNotificationsEnabled = false
@AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false
func checkPushNotificationsStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled
}
}
}
func tryUpdateToDesired(dataService: DataService) {
UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue)
if desiredNotificationsEnabled {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
DispatchQueue.main.async {
self.desiredNotificationsEnabled = granted
Task {
if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) {
try? await dataService.syncDeviceToken(
deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken))
}
NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil)
}
}
}
} else {
if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) {
Task {
try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
}
}
}
}
}
struct PushNotificationSettingsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = PushNotificationSettingsViewModel()
@State var desiredNotificationsEnabled: Bool = false
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.task { viewModel.checkPushNotificationsStatus() }
}
private var innerBody: some View {
Group {
Section {
Toggle(isOn: $viewModel.desiredNotificationsEnabled, label: { Text("Notifications Enabled") })
}.onChange(of: viewModel.desiredNotificationsEnabled) { _ in
viewModel.tryUpdateToDesired(dataService: dataService)
}
Section {
Text("""
Enabling push notifications gives Omnivore device permission to send notifications, \
but you are in charge of which notifications are sent.
Push notifications are triggered using your \
[account rules](https://omnivore.app/settings/rules) which you can edit online.
""")
.accentColor(.blue)
}
Section {
NavigationLink("Devices") {
PushNotificationDevicesView()
}
}
}
.navigationTitle("Push Notifications")
}
}

View file

@ -5,7 +5,9 @@ import Views
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = true
@Published var isLeaving = false
@Published var networkError = false
@Published var showLeaveGroup = false
@Published var recommendationGroup: InternalRecommendationGroup
init(recommendationGroup: InternalRecommendationGroup) {
@ -17,6 +19,21 @@ import Views
!recommendationGroup.admins.contains(where: { member.id == $0.id })
}
}
func leaveGroup(dataService: DataService) async -> Bool {
isLeaving = true
defer {
isLeaving = false
}
do {
try await dataService.leaveGroup(groupID: recommendationGroup.id)
Snackbar.show(message: "You have left the group.")
} catch {
return false
}
return true
}
}
private struct SmallUserCard: View {
@ -68,11 +85,11 @@ private struct SmallUserCard: View {
}
struct RecommendationGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var dataService: DataService
@StateObject var viewModel: RecommendationsGroupViewModel
@State var presentShareSheet = false
var body: some View {
Group {
#if os(iOS)
@ -110,16 +127,43 @@ 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.recommendationGroup.canSeeMembers {
Text("""
The admin of this group does not allow viewing all members.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else 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)
}
}
}
private var leaveSection: some View {
if viewModel.isLeaving {
return AnyView(ProgressView())
}
return AnyView(Button(action: {
viewModel.showLeaveGroup = true
}, label: { Text("Leave Group") })
.accentColor(.red))
}
private var innerBody: some View {
Group {
Section("Name") {
@ -128,7 +172,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)
@ -137,9 +191,22 @@ struct RecommendationGroupView: View {
adminsSection
membersSection
leaveSection
}
.formSheet(isPresented: $presentShareSheet) {
shareView
.alert(isPresented: $viewModel.showLeaveGroup) {
Alert(
title: Text("Are you sure you want to leave this group? No data will be deleted, but you will stop receiving recommendations from the group."),
primaryButton: .destructive(Text("Leave Group")) {
Task {
let success = await viewModel.leaveGroup(dataService: dataService)
if success {
dismiss()
}
}
},
secondaryButton: .cancel()
)
}
.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
@ -14,6 +14,9 @@ import Views
@Published var showCreateError = false
@Published var createGroupError: String?
@Published var onlyAdminCanPost = false
@Published var onlyAdminCanSeeMembers = false
func loadGroups(dataService: DataService) async {
isLoading = true
@ -29,8 +32,11 @@ import Views
func createGroup(dataService: DataService, name: String) async {
isCreating = true
if let group = try? await dataService.createRecommendationGroup(name: name) {
print("CREATED GROUP: ", group)
let group = try? await dataService.createRecommendationGroup(name: name,
onlyAdminCanPost: onlyAdminCanPost,
onlyAdminCanSeeMembers: onlyAdminCanSeeMembers)
if group != nil {
await loadGroups(dataService: dataService)
showCreateSheet = false
} else {
@ -67,6 +73,20 @@ struct CreateRecommendationGroupView: View {
NavigationView {
Form {
TextField("Name", text: $name, prompt: Text("Group Name"))
Section {
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
}
Section {
Section {
Text("""
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
}
.alert(isPresented: $viewModel.showCreateError) {
Alert(
@ -112,6 +132,7 @@ struct GroupsView: View {
}
}
.task { await viewModel.loadGroups(dataService: dataService) }
.navigationTitle("Recommendation Groups")
}
private var innerBody: some View {
@ -127,19 +148,35 @@ 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.isLoading {
if viewModel.recommendationGroups.count > 0 {
Section(header: Text("Your recommendation groups")) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
let vm = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
NavigationLink(
destination: RecommendationGroupView(viewModel: vm)
) {
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,33 @@ 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()
#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 +108,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
@ -33,33 +35,9 @@ public final class RootViewModel: ObservableObject {
#endif
}
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
}
#if os(iOS)
func handlePushNotificationPrimerAcceptance() {
showPushNotificationPrimer = false
UNUserNotificationCenter.current().requestAuth()
// UNUserNotificationCenter.current().requestAuth()
}
#endif
}

View file

@ -1,3 +1,4 @@
import CoreData
import Models
import Services
import SwiftUI
@ -5,24 +6,42 @@ 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 = ""
@Published var withHighlights: Bool = true
let pageID: String
let highlightCount: Int
init(pageID: String) {
init(pageID: String, highlightCount: Int) {
self.pageID = pageID
self.highlightCount = highlightCount
}
func loadGroups(dataService: DataService) async {
isLoading = true
do {
recommendationGroups = try await dataService.recommendationGroups()
dataService.viewContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.createdAt), ascending: false)
fetchRequest.sortDescriptors = [sort]
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
// If this fails we will fallback to making the API call
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
InternalRecommendationGroup.make(from: object)
}
if let groups = groups {
self.recommendationGroups = groups
}
}
recommendationGroups = try await dataService.recommendationGroups().filter(\.canPost)
} catch {
print("ERROR fetching recommendationGroups: ", error)
networkError = true
@ -31,16 +50,22 @@ import Views
isLoading = false
}
func recommend(dataService: DataService) async {
func recommend(dataService: DataService) async -> Bool {
isRunning = true
defer { isRunning = false }
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,
withHighlights: withHighlights)
} catch {
showError = true
return false
}
isRunning = false
return true
}
}
@ -50,44 +75,118 @@ 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 {
return AnyView(Button(action: {
Task {
await viewModel.recommend(dataService: dataService)
Snackbar.show(message: "Recommendation sent")
dismiss()
if await viewModel.recommend(dataService: dataService) {
Snackbar.show(message: "Recommendation sent")
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)
)
if viewModel.highlightCount > 0 {
Toggle(isOn: $viewModel.withHighlights, label: {
HStack(alignment: .firstTextBaseline) {
Text("Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
}
})
}
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
HStack {
Text(group.name)
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
Text("""
You do not have any groups you can post to.
Spacer()
Join a group or create your own to start recommending articles.
if viewModel.selectedGroups.contains(group.id) {
Image(systemName: "checkmark")
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else {
Section("Select groups to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)
Spacer()
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
Image(systemName: "checkmark")
}
}
}
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(of: group.id)
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group.id)
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group)
}
}
}
}
@ -105,7 +204,6 @@ struct RecommendToView: View {
}
)
}
.navigationBarTitle("Recommend")
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(leading: Button(action: {

View file

@ -359,10 +359,12 @@ struct WebReaderContainerView: View {
})
}
.formSheet(isPresented: $showRecommendSheet) {
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID)
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
highlightCount: highlightCount)
)
}.onDisappear {
showRecommendSheet = false

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

@ -13,6 +13,7 @@
<attribute name="shortId" attributeType="String"/>
<attribute name="suffix" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="createdBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="highlights" inverseEntity="LinkedItem"/>
<uniquenessConstraints>
@ -55,7 +56,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"/>
@ -93,13 +94,16 @@
<attribute name="term" optional="YES" attributeType="String"/>
</entity>
<entity name="Recommendation" representedClassName="Recommendation" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="groupID" 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">
<attribute name="canPost" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="canSeeMembers" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="inviteUrl" optional="YES" attributeType="String"/>

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.groupID ?? ""),
"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

@ -2,20 +2,37 @@ import CoreData
import Foundation
public extension Recommendation {
var unwrappedID: String { id ?? "" }
// Returns the recommendations from other users, filtering out the viewer
// if they have also recommended the page.
static func notViewers(viewer: Viewer?, _ set: NSSet?) -> [Recommendation] {
Array(set ?? [])
.compactMap { $0 as? Recommendation }
.filter { $0.user?.userID != viewer?.userID }
}
static func lookup(byID recommendationID: String, inContext context: NSManagedObjectContext) -> Recommendation? {
let fetchRequest: NSFetchRequest<Models.Recommendation> = Recommendation.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "id == %@", recommendationID
)
var recommendation: Recommendation?
context.performAndWait {
recommendation = (try? context.fetch(fetchRequest))?.first
static func byline(_ recommendations: [Recommendation]) -> String {
recommendations.reduce("") { str, recommendation in
if let userName = recommendation.user?.name {
if str.isEmpty {
return userName
} else {
return str + ", " + userName
}
}
return str
}
}
return recommendation
static func groupsLine(_ recommendations: [Recommendation]) -> String {
recommendations.reduce("") { str, recommendation in
if let name = recommendation.name {
if str.isEmpty {
return name
} else {
return str + ", " + name
}
}
return str
}
}
}

View file

@ -4,6 +4,7 @@ public enum LinkedItemFilter: String, CaseIterable {
case inbox
case readlater
case newsletters
case recommended
case all
case archived
case hasHighlights
@ -19,6 +20,8 @@ public extension LinkedItemFilter {
return "Read Later"
case .newsletters:
return "Newsletters"
case .recommended:
return "Recommended"
case .all:
return "All"
case .archived:
@ -38,6 +41,8 @@ public extension LinkedItemFilter {
return "in:inbox -label:Newsletter"
case .newsletters:
return "in:inbox label:Newsletter"
case .recommended:
return "recommendedBy:*"
case .all:
return "in:all"
case .archived:
@ -75,6 +80,12 @@ public extension LinkedItemFilter {
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
case .recommended:
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate])
case .all:
// include everything undeleted
return undeletedPredicate

View file

@ -8496,6 +8496,136 @@ extension Selection where TypeLock == Never, Type == Never {
typealias LabelsSuccess<T> = Selection<T, Objects.LabelsSuccess>
}
extension Objects {
struct LeaveGroupError {
let __typename: TypeName = .leaveGroupError
let errorCodes: [String: [Enums.LeaveGroupErrorCode]]
enum TypeName: String, Codable {
case leaveGroupError = "LeaveGroupError"
}
}
}
extension Objects.LeaveGroupError: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "errorCodes":
if let value = try container.decode([Enums.LeaveGroupErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
errorCodes = map["errorCodes"]
}
}
extension Fields where TypeLock == Objects.LeaveGroupError {
func errorCodes() throws -> [Enums.LeaveGroupErrorCode] {
let field = GraphQLField.leaf(
name: "errorCodes",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.errorCodes[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return []
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias LeaveGroupError<T> = Selection<T, Objects.LeaveGroupError>
}
extension Objects {
struct LeaveGroupSuccess {
let __typename: TypeName = .leaveGroupSuccess
let success: [String: Bool]
enum TypeName: String, Codable {
case leaveGroupSuccess = "LeaveGroupSuccess"
}
}
}
extension Objects.LeaveGroupSuccess: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "success":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
success = map["success"]
}
}
extension Fields where TypeLock == Objects.LeaveGroupSuccess {
func success() throws -> Bool {
let field = GraphQLField.leaf(
name: "success",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.success[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return Bool.mockValue
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias LeaveGroupSuccess<T> = Selection<T, Objects.LeaveGroupSuccess>
}
extension Objects {
struct Link {
let __typename: TypeName = .link
@ -9671,12 +9801,14 @@ extension Objects {
let googleLogin: [String: Unions.LoginResult]
let googleSignup: [String: Unions.GoogleSignupResult]
let joinGroup: [String: Unions.JoinGroupResult]
let leaveGroup: [String: Unions.LeaveGroupResult]
let logOut: [String: Unions.LogOutResult]
let mergeHighlight: [String: Unions.MergeHighlightResult]
let moveFilter: [String: Unions.MoveFilterResult]
let moveLabel: [String: Unions.MoveLabelResult]
let optInFeature: [String: Unions.OptInFeatureResult]
let recommend: [String: Unions.RecommendResult]
let recommendHighlights: [String: Unions.RecommendHighlightsResult]
let reportItem: [String: Objects.ReportItemResult]
let revokeApiKey: [String: Unions.RevokeApiKeyResult]
let saveArticleReadingProgress: [String: Unions.SaveArticleReadingProgressResult]
@ -9827,6 +9959,10 @@ extension Objects.Mutation: Decodable {
if let value = try container.decode(Unions.JoinGroupResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "leaveGroup":
if let value = try container.decode(Unions.LeaveGroupResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "logOut":
if let value = try container.decode(Unions.LogOutResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -9851,6 +9987,10 @@ extension Objects.Mutation: Decodable {
if let value = try container.decode(Unions.RecommendResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "recommendHighlights":
if let value = try container.decode(Unions.RecommendHighlightsResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "reportItem":
if let value = try container.decode(Objects.ReportItemResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -10010,12 +10150,14 @@ extension Objects.Mutation: Decodable {
googleLogin = map["googleLogin"]
googleSignup = map["googleSignup"]
joinGroup = map["joinGroup"]
leaveGroup = map["leaveGroup"]
logOut = map["logOut"]
mergeHighlight = map["mergeHighlight"]
moveFilter = map["moveFilter"]
moveLabel = map["moveLabel"]
optInFeature = map["optInFeature"]
recommend = map["recommend"]
recommendHighlights = map["recommendHighlights"]
reportItem = map["reportItem"]
revokeApiKey = map["revokeApiKey"]
saveArticleReadingProgress = map["saveArticleReadingProgress"]
@ -10526,6 +10668,25 @@ extension Fields where TypeLock == Objects.Mutation {
}
}
func leaveGroup<Type>(groupId: String, selection: Selection<Type, Unions.LeaveGroupResult>) throws -> Type {
let field = GraphQLField.composite(
name: "leaveGroup",
arguments: [Argument(name: "groupId", type: "ID!", value: groupId)],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
if let data = data.leaveGroup[field.alias!] {
return try selection.decode(data: data)
}
throw HttpError.badpayload
case .mocking:
return selection.mock()
}
}
func logOut<Type>(selection: Selection<Type, Unions.LogOutResult>) throws -> Type {
let field = GraphQLField.composite(
name: "logOut",
@ -10640,6 +10801,25 @@ extension Fields where TypeLock == Objects.Mutation {
}
}
func recommendHighlights<Type>(input: InputObjects.RecommendHighlightsInput, selection: Selection<Type, Unions.RecommendHighlightsResult>) throws -> Type {
let field = GraphQLField.composite(
name: "recommendHighlights",
arguments: [Argument(name: "input", type: "RecommendHighlightsInput!", value: input)],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
if let data = data.recommendHighlights[field.alias!] {
return try selection.decode(data: data)
}
throw HttpError.badpayload
case .mocking:
return selection.mock()
}
}
func reportItem<Type>(input: InputObjects.ReportItemInput, selection: Selection<Type, Objects.ReportItemResult>) throws -> Type {
let field = GraphQLField.composite(
name: "reportItem",
@ -13663,10 +13843,140 @@ extension Selection where TypeLock == Never, Type == Never {
typealias RecommendError<T> = Selection<T, Objects.RecommendError>
}
extension Objects {
struct RecommendHighlightsError {
let __typename: TypeName = .recommendHighlightsError
let errorCodes: [String: [Enums.RecommendHighlightsErrorCode]]
enum TypeName: String, Codable {
case recommendHighlightsError = "RecommendHighlightsError"
}
}
}
extension Objects.RecommendHighlightsError: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "errorCodes":
if let value = try container.decode([Enums.RecommendHighlightsErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
errorCodes = map["errorCodes"]
}
}
extension Fields where TypeLock == Objects.RecommendHighlightsError {
func errorCodes() throws -> [Enums.RecommendHighlightsErrorCode] {
let field = GraphQLField.leaf(
name: "errorCodes",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.errorCodes[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return []
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias RecommendHighlightsError<T> = Selection<T, Objects.RecommendHighlightsError>
}
extension Objects {
struct RecommendHighlightsSuccess {
let __typename: TypeName = .recommendHighlightsSuccess
let success: [String: Bool]
enum TypeName: String, Codable {
case recommendHighlightsSuccess = "RecommendHighlightsSuccess"
}
}
}
extension Objects.RecommendHighlightsSuccess: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "success":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
success = map["success"]
}
}
extension Fields where TypeLock == Objects.RecommendHighlightsSuccess {
func success() throws -> Bool {
let field = GraphQLField.leaf(
name: "success",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.success[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return Bool.mockValue
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias RecommendHighlightsSuccess<T> = Selection<T, Objects.RecommendHighlightsSuccess>
}
extension Objects {
struct RecommendSuccess {
let __typename: TypeName = .recommendSuccess
let taskNames: [String: [String]]
let success: [String: Bool]
enum TypeName: String, Codable {
case recommendSuccess = "RecommendSuccess"
@ -13686,8 +13996,8 @@ extension Objects.RecommendSuccess: Decodable {
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "taskNames":
if let value = try container.decode([String]?.self, forKey: codingKey) {
case "success":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
@ -13700,26 +14010,26 @@ extension Objects.RecommendSuccess: Decodable {
}
}
taskNames = map["taskNames"]
success = map["success"]
}
}
extension Fields where TypeLock == Objects.RecommendSuccess {
func taskNames() throws -> [String] {
func success() throws -> Bool {
let field = GraphQLField.leaf(
name: "taskNames",
name: "success",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.taskNames[field.alias!] {
if let data = data.success[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return []
return Bool.mockValue
}
}
}
@ -13888,11 +14198,15 @@ extension Objects {
struct RecommendationGroup {
let __typename: TypeName = .recommendationGroup
let admins: [String: [Objects.User]]
let canPost: [String: Bool]
let canSeeMembers: [String: Bool]
let createdAt: [String: DateTime]
let description: [String: String]
let id: [String: String]
let inviteUrl: [String: String]
let members: [String: [Objects.User]]
let name: [String: String]
let topics: [String: [String]]
let updatedAt: [String: DateTime]
enum TypeName: String, Codable {
@ -13917,10 +14231,22 @@ extension Objects.RecommendationGroup: Decodable {
if let value = try container.decode([Objects.User]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "canPost":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "canSeeMembers":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "createdAt":
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "description":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "id":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -13937,6 +14263,10 @@ extension Objects.RecommendationGroup: Decodable {
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "topics":
if let value = try container.decode([String]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "updatedAt":
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -13952,11 +14282,15 @@ extension Objects.RecommendationGroup: Decodable {
}
admins = map["admins"]
canPost = map["canPost"]
canSeeMembers = map["canSeeMembers"]
createdAt = map["createdAt"]
description = map["description"]
id = map["id"]
inviteUrl = map["inviteUrl"]
members = map["members"]
name = map["name"]
topics = map["topics"]
updatedAt = map["updatedAt"]
}
}
@ -13981,6 +14315,42 @@ extension Fields where TypeLock == Objects.RecommendationGroup {
}
}
func canPost() throws -> Bool {
let field = GraphQLField.leaf(
name: "canPost",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.canPost[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return Bool.mockValue
}
}
func canSeeMembers() throws -> Bool {
let field = GraphQLField.leaf(
name: "canSeeMembers",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.canSeeMembers[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return Bool.mockValue
}
}
func createdAt() throws -> DateTime {
let field = GraphQLField.leaf(
name: "createdAt",
@ -13999,6 +14369,21 @@ extension Fields where TypeLock == Objects.RecommendationGroup {
}
}
func description() throws -> String? {
let field = GraphQLField.leaf(
name: "description",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.description[field.alias!]
case .mocking:
return nil
}
}
func id() throws -> String {
let field = GraphQLField.leaf(
name: "id",
@ -14072,6 +14457,21 @@ extension Fields where TypeLock == Objects.RecommendationGroup {
}
}
func topics() throws -> [String]? {
let field = GraphQLField.leaf(
name: "topics",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.topics[field.alias!]
case .mocking:
return nil
}
}
func updatedAt() throws -> DateTime {
let field = GraphQLField.leaf(
name: "updatedAt",
@ -25164,6 +25564,80 @@ extension Selection where TypeLock == Never, Type == Never {
typealias LabelsResult<T> = Selection<T, Unions.LabelsResult>
}
extension Unions {
struct LeaveGroupResult {
let __typename: TypeName
let errorCodes: [String: [Enums.LeaveGroupErrorCode]]
let success: [String: Bool]
enum TypeName: String, Codable {
case leaveGroupError = "LeaveGroupError"
case leaveGroupSuccess = "LeaveGroupSuccess"
}
}
}
extension Unions.LeaveGroupResult: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "errorCodes":
if let value = try container.decode([Enums.LeaveGroupErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "success":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
__typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!)
errorCodes = map["errorCodes"]
success = map["success"]
}
}
extension Fields where TypeLock == Unions.LeaveGroupResult {
func on<Type>(leaveGroupError: Selection<Type, Objects.LeaveGroupError>, leaveGroupSuccess: Selection<Type, Objects.LeaveGroupSuccess>) throws -> Type {
select([GraphQLField.fragment(type: "LeaveGroupError", selection: leaveGroupError.selection), GraphQLField.fragment(type: "LeaveGroupSuccess", selection: leaveGroupSuccess.selection)])
switch response {
case let .decoding(data):
switch data.__typename {
case .leaveGroupError:
let data = Objects.LeaveGroupError(errorCodes: data.errorCodes)
return try leaveGroupError.decode(data: data)
case .leaveGroupSuccess:
let data = Objects.LeaveGroupSuccess(success: data.success)
return try leaveGroupSuccess.decode(data: data)
}
case .mocking:
return leaveGroupError.mock()
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias LeaveGroupResult<T> = Selection<T, Unions.LeaveGroupResult>
}
extension Unions {
struct LogOutResult {
let __typename: TypeName
@ -25762,11 +26236,85 @@ extension Selection where TypeLock == Never, Type == Never {
typealias RecentSearchesResult<T> = Selection<T, Unions.RecentSearchesResult>
}
extension Unions {
struct RecommendHighlightsResult {
let __typename: TypeName
let errorCodes: [String: [Enums.RecommendHighlightsErrorCode]]
let success: [String: Bool]
enum TypeName: String, Codable {
case recommendHighlightsError = "RecommendHighlightsError"
case recommendHighlightsSuccess = "RecommendHighlightsSuccess"
}
}
}
extension Unions.RecommendHighlightsResult: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "errorCodes":
if let value = try container.decode([Enums.RecommendHighlightsErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "success":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
__typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!)
errorCodes = map["errorCodes"]
success = map["success"]
}
}
extension Fields where TypeLock == Unions.RecommendHighlightsResult {
func on<Type>(recommendHighlightsError: Selection<Type, Objects.RecommendHighlightsError>, recommendHighlightsSuccess: Selection<Type, Objects.RecommendHighlightsSuccess>) throws -> Type {
select([GraphQLField.fragment(type: "RecommendHighlightsError", selection: recommendHighlightsError.selection), GraphQLField.fragment(type: "RecommendHighlightsSuccess", selection: recommendHighlightsSuccess.selection)])
switch response {
case let .decoding(data):
switch data.__typename {
case .recommendHighlightsError:
let data = Objects.RecommendHighlightsError(errorCodes: data.errorCodes)
return try recommendHighlightsError.decode(data: data)
case .recommendHighlightsSuccess:
let data = Objects.RecommendHighlightsSuccess(success: data.success)
return try recommendHighlightsSuccess.decode(data: data)
}
case .mocking:
return recommendHighlightsError.mock()
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias RecommendHighlightsResult<T> = Selection<T, Unions.RecommendHighlightsResult>
}
extension Unions {
struct RecommendResult {
let __typename: TypeName
let errorCodes: [String: [Enums.RecommendErrorCode]]
let taskNames: [String: [String]]
let success: [String: Bool]
enum TypeName: String, Codable {
case recommendError = "RecommendError"
@ -25791,8 +26339,8 @@ extension Unions.RecommendResult: Decodable {
if let value = try container.decode([Enums.RecommendErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "taskNames":
if let value = try container.decode([String]?.self, forKey: codingKey) {
case "success":
if let value = try container.decode(Bool?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
@ -25808,7 +26356,7 @@ extension Unions.RecommendResult: Decodable {
__typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!)
errorCodes = map["errorCodes"]
taskNames = map["taskNames"]
success = map["success"]
}
}
@ -25823,7 +26371,7 @@ extension Fields where TypeLock == Unions.RecommendResult {
let data = Objects.RecommendError(errorCodes: data.errorCodes)
return try recommendError.decode(data: data)
case .recommendSuccess:
let data = Objects.RecommendSuccess(taskNames: data.taskNames)
let data = Objects.RecommendSuccess(success: data.success)
return try recommendSuccess.decode(data: data)
}
case .mocking:
@ -29127,6 +29675,17 @@ extension Enums {
}
}
extension Enums {
/// LeaveGroupErrorCode
enum LeaveGroupErrorCode: String, CaseIterable, Codable {
case badRequest = "BAD_REQUEST"
case notFound = "NOT_FOUND"
case unauthorized = "UNAUTHORIZED"
}
}
extension Enums {
/// LogOutErrorCode
enum LogOutErrorCode: String, CaseIterable, Codable {
@ -29262,6 +29821,17 @@ extension Enums {
}
}
extension Enums {
/// RecommendHighlightsErrorCode
enum RecommendHighlightsErrorCode: String, CaseIterable, Codable {
case badRequest = "BAD_REQUEST"
case notFound = "NOT_FOUND"
case unauthorized = "UNAUTHORIZED"
}
}
extension Enums {
/// ReminderErrorCode
enum ReminderErrorCode: String, CaseIterable, Codable {
@ -29882,23 +30452,39 @@ extension InputObjects {
extension InputObjects {
struct CreateGroupInput: Encodable, Hashable {
var description: OptionalArgument<String> = .absent()
var expiresInDays: OptionalArgument<Int> = .absent()
var maxMembers: OptionalArgument<Int> = .absent()
var name: String
var onlyAdminCanPost: OptionalArgument<Bool> = .absent()
var onlyAdminCanSeeMembers: OptionalArgument<Bool> = .absent()
var topics: OptionalArgument<[String]> = .absent()
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
if description.hasValue { try container.encode(description, forKey: .description) }
if expiresInDays.hasValue { try container.encode(expiresInDays, forKey: .expiresInDays) }
if maxMembers.hasValue { try container.encode(maxMembers, forKey: .maxMembers) }
try container.encode(name, forKey: .name)
if onlyAdminCanPost.hasValue { try container.encode(onlyAdminCanPost, forKey: .onlyAdminCanPost) }
if onlyAdminCanSeeMembers.hasValue { try container.encode(onlyAdminCanSeeMembers, forKey: .onlyAdminCanSeeMembers) }
if topics.hasValue { try container.encode(topics, forKey: .topics) }
}
enum CodingKeys: String, CodingKey {
case description
case expiresInDays
case maxMembers
case name
case onlyAdminCanPost
case onlyAdminCanSeeMembers
case topics
}
}
}
@ -30302,9 +30888,11 @@ extension InputObjects {
}
extension InputObjects {
struct RecommendInput: Encodable, Hashable {
struct RecommendHighlightsInput: Encodable, Hashable {
var groupIds: [String]
var highlightIds: [String]
var note: OptionalArgument<String> = .absent()
var pageId: String
@ -30312,18 +30900,47 @@ extension InputObjects {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(groupIds, forKey: .groupIds)
try container.encode(highlightIds, forKey: .highlightIds)
if note.hasValue { try container.encode(note, forKey: .note) }
try container.encode(pageId, forKey: .pageId)
}
enum CodingKeys: String, CodingKey {
case groupIds
case highlightIds
case note
case pageId
}
}
}
extension InputObjects {
struct RecommendInput: Encodable, Hashable {
var groupIds: [String]
var note: OptionalArgument<String> = .absent()
var pageId: String
var recommendedWithHighlights: OptionalArgument<Bool> = .absent()
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(groupIds, forKey: .groupIds)
if note.hasValue { try container.encode(note, forKey: .note) }
try container.encode(pageId, forKey: .pageId)
if recommendedWithHighlights.hasValue { try container.encode(recommendedWithHighlights, forKey: .recommendedWithHighlights) }
}
enum CodingKeys: String, CodingKey {
case groupIds
case note
case pageId
case recommendedWithHighlights
}
}
}
extension InputObjects {
struct ReportItemInput: Encodable, Hashable {
var itemUrl: String

View file

@ -22,6 +22,7 @@ extension DataService {
createdAt: nil,
updatedAt: nil,
createdByMe: true,
createdBy: nil,
labels: []
)

View file

@ -4,7 +4,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func createRecommendationGroup(name: String) async throws -> InternalRecommendationGroup {
func createRecommendationGroup(name: String, onlyAdminCanPost: Bool, onlyAdminCanSeeMembers: Bool) async throws -> InternalRecommendationGroup {
enum MutationResult {
case saved(recommendationGroup: InternalRecommendationGroup)
case error(errorCode: Enums.CreateGroupErrorCode)
@ -21,7 +21,10 @@ public extension DataService {
)
}
let input = InputObjects.CreateGroupInput(name: name)
let input = InputObjects.CreateGroupInput(expiresInDays: OptionalArgument(14),
name: name,
onlyAdminCanPost: OptionalArgument(onlyAdminCanPost),
onlyAdminCanSeeMembers: OptionalArgument(onlyAdminCanSeeMembers))
let mutation = Selection.Mutation {
try $0.createGroup(input: input, selection: selection)

View file

@ -1,6 +1,7 @@
import Foundation
import Models
import SwiftGraphQL
import Utils
public enum DeviceTokenOperation {
case addToken(token: String)
@ -26,17 +27,22 @@ public enum DeviceTokenOperation {
}
public extension DataService {
func syncDeviceToken(deviceTokenOperation: DeviceTokenOperation) {
func syncDeviceToken(deviceTokenOperation: DeviceTokenOperation) async throws -> String? {
enum MutationResult {
case saved(id: String)
case saved(id: String, token: String?)
case error(errorCode: Enums.SetDeviceTokenErrorCode)
}
let success = Selection.DeviceToken { (id: try $0.id(), token: try $0.token()) }
let selection = Selection<MutationResult, Unions.SetDeviceTokenResult> {
try $0.on(
setDeviceTokenError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
setDeviceTokenSuccess: .init {
.saved(id: try $0.deviceToken(selection: Selection.DeviceToken { try $0.id() }))
.saved(
id: try $0.deviceToken(selection: success).id,
token: try $0.deviceToken(selection: success).token
)
}
)
}
@ -54,6 +60,30 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
send(mutation, to: path, headers: headers) { _ in }
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { result in
guard let payload = try? result.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .saved(id: id, token: token):
switch deviceTokenOperation {
case .deleteToken(tokenID: _):
// When we delete we don't remove the saved token, as we might need to re-use it
// in the future
UserDefaults.standard.removeObject(forKey: UserDefaultKey.deviceTokenID.rawValue)
case .addToken(token: _):
UserDefaults.standard.set(id, forKey: UserDefaultKey.deviceTokenID.rawValue)
UserDefaults.standard.set(token, forKey: UserDefaultKey.firebasePushToken.rawValue)
}
continuation.resume(returning: id)
case let .error(errorCode: errorCode):
continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue))
}
}
}
}
}

View file

@ -0,0 +1,53 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func leaveGroup(groupID: String) async throws {
enum MutationResult {
case saved(success: Bool)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.LeaveGroupResult> {
try $0.on(
leaveGroupError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) },
leaveGroupSuccess: .init {
.saved(success: try $0.success())
}
)
}
let mutation = Selection.Mutation {
try $0.leaveGroup(
groupId: groupID,
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .saved(success):
if success {
continuation.resume()
} else {
continuation.resume(throwing: BasicError.message(messageText: "Unknown error"))
}
return
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
}
}
}
}
}

View file

@ -24,6 +24,7 @@ extension DataService {
createdAt: nil,
updatedAt: nil,
createdByMe: true,
createdBy: nil,
labels: []
)

View file

@ -4,9 +4,9 @@ import Models
import SwiftGraphQL
public extension DataService {
func recommendPage(pageID: String, groupIDs: [String], note: String?) async throws {
func recommendPage(pageID: String, groupIDs: [String], note: String?, withHighlights: Bool?) async throws {
enum MutationResult {
case saved(taskNames: [String])
case saved(success: Bool)
case error(errorMessage: String)
}
@ -14,14 +14,14 @@ public extension DataService {
try $0.on(
recommendError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) },
recommendSuccess: .init {
.saved(taskNames: try $0.taskNames())
.saved(success: try $0.success())
}
)
}
let mutation = Selection.Mutation {
try $0.recommend(
input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID),
input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID, recommendedWithHighlights: OptionalArgument(withHighlights)),
selection: selection
)
}

View file

@ -0,0 +1,55 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func devices() async throws -> [InternalDeviceToken] {
enum QueryResult {
case success(result: [InternalDeviceToken])
case error(error: String)
}
let deviceTokensSelection = Selection.DeviceToken {
InternalDeviceToken(
id: try $0.id(),
createdAt: try $0.createdAt().value
)
}
let selection = Selection<QueryResult, Unions.DeviceTokensResult> {
try $0.on(
deviceTokensError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
deviceTokensSuccess: .init {
QueryResult.success(result: try $0.deviceTokens(selection: deviceTokensSelection.list))
}
)
}
let query = Selection.Query {
try $0.deviceTokens(selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network request failed"))
return
}
switch payload.data {
case let .success(result: result):
continuation.resume(returning: result)
case .error:
continuation.resume(throwing: BasicError.message(messageText: "DeviceToken Email fetch error"))
}
}
}
}
}

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(
groupID: 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

@ -23,6 +23,7 @@ let highlightSelection = Selection.Highlight {
createdAt: try $0.createdAt().value,
updatedAt: try $0.updatedAt().value,
createdByMe: try $0.createdByMe(),
createdBy: try $0.user(selection: userProfileSelection),
labels: try $0.labels(selection: highlightLabelSelection.list.nullable) ?? []
)
}

View file

@ -6,6 +6,8 @@ let recommendationGroupSelection = Selection.RecommendationGroup {
id: try $0.id(),
name: try $0.name(),
inviteUrl: try $0.inviteUrl(),
canPost: try $0.canPost(),
canSeeMembers: try $0.canSeeMembers(),
admins: try $0.admins(selection: userProfileSelection.list),
members: try $0.members(selection: userProfileSelection.list)
)

View file

@ -0,0 +1,8 @@
import CoreData
import Foundation
import Models
public struct InternalDeviceToken: Identifiable {
public let id: String
public let createdAt: Date?
}

View file

@ -13,6 +13,7 @@ struct InternalHighlight: Encodable {
let createdAt: Date?
let updatedAt: Date?
let createdByMe: Bool
let createdBy: InternalUserProfile?
var labels: [InternalLinkedItemLabel]
func asManagedObject(context: NSManagedObjectContext) -> Highlight {
@ -35,6 +36,10 @@ struct InternalHighlight: Encodable {
highlight.updatedAt = updatedAt
highlight.createdByMe = createdByMe
if let createdBy = createdBy {
highlight.createdBy = createdBy.asManagedObject(inContext: context)
}
if let existingLabels = highlight.labels {
highlight.removeFromLabels(existingLabels)
}
@ -58,6 +63,7 @@ struct InternalHighlight: Encodable {
createdAt: highlight.createdAt,
updatedAt: highlight.updatedAt,
createdByMe: highlight.createdByMe,
createdBy: InternalUserProfile.makeSingle(highlight.createdBy),
labels: InternalLinkedItemLabel.make(highlight.labels)
)
}

View file

@ -3,16 +3,17 @@ import Foundation
import Models
public struct InternalRecommendation {
let id: String
let groupID: String
let name: String
let note: String?
let user: InternalUserProfile?
let recommendedAt: Date
func asManagedObject(inContext context: NSManagedObjectContext) -> Recommendation {
let existing = Recommendation.lookup(byID: id, inContext: context)
let recommendation = existing ?? Recommendation(entity: Recommendation.entity(), insertInto: context)
recommendation.id = id
let recommendation = Recommendation(entity: Recommendation.entity(), insertInto: context)
recommendation.groupID = groupID
recommendation.name = name
recommendation.note = note
recommendation.recommendedAt = recommendedAt
recommendation.user = user?.asManagedObject(inContext: context)
return recommendation
@ -22,13 +23,14 @@ public struct InternalRecommendation {
recommendations?
.compactMap { recommendation in
if let recommendation = recommendation as? Recommendation,
let id = recommendation.id,
let groupID = recommendation.groupID,
let name = recommendation.name,
let recommendedAt = recommendation.recommendedAt
{
return InternalRecommendation(
id: id,
groupID: groupID,
name: name,
note: recommendation.note,
user: InternalUserProfile.makeSingle(recommendation.user),
recommendedAt: recommendedAt
)

View file

@ -13,6 +13,8 @@ public struct InternalRecommendationGroup: Identifiable {
public let id: String
public let name: String
public let inviteUrl: String
public let canPost: Bool
public let canSeeMembers: Bool
public let admins: [InternalUserProfile]
public let members: [InternalUserProfile]
@ -26,12 +28,14 @@ public struct InternalRecommendationGroup: Identifiable {
recommendationGroup.id = id
recommendationGroup.name = name
recommendationGroup.canPost = canPost
recommendationGroup.canSeeMembers = canSeeMembers
recommendationGroup.inviteUrl = inviteUrl
return recommendationGroup
}
static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? {
public static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? {
if let id = recommendationGroup.id,
let name = recommendationGroup.name,
let inviteUrl = recommendationGroup.inviteUrl
@ -40,12 +44,24 @@ public struct InternalRecommendationGroup: Identifiable {
id: id,
name: name,
inviteUrl: inviteUrl,
canPost: recommendationGroup.canPost,
canSeeMembers: recommendationGroup.canSeeMembers,
admins: InternalUserProfile.make(recommendationGroup.admins),
members: InternalUserProfile.make(recommendationGroup.members)
)
}
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

@ -2,7 +2,7 @@ import CoreData
import Foundation
import Models
public struct InternalUserProfile: Identifiable {
public struct InternalUserProfile: Identifiable, Encodable {
let userID: String
public let name: String
public let username: String

View file

@ -9,8 +9,6 @@ import Foundation
public enum FeatureFlag {
public static let enableSnoozeFromShareExtension = false
public static let enableRemindersFromShareExtension = false
public static let enableReadNow = false
public static let enablePushNotifications = false
public static let enableShareButton = false
public static let enableSnooze = false
public static let enableGridCardsOnPhone = false

View file

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

View file

@ -0,0 +1,38 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0xE5",
"green": "0xFF",
"red": "0xE5"
}
},
"idiom": "universal"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0xE5",
"green": "0xFF",
"red": "0xE5"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -1,38 +1,38 @@
{
"colors" : [
"colors": [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x13",
"green" : "0xB5",
"red" : "0xE2"
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0x92",
"green": "0xe3",
"red": "0xfa"
}
},
"idiom" : "universal"
"idiom": "universal"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "dark"
"appearance": "luminosity",
"value": "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x13",
"green" : "0xB5",
"red" : "0xE2"
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0x92",
"green": "0xe3",
"red": "0xfa"
}
},
"idiom" : "universal"
"idiom": "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
}

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

@ -3,11 +3,13 @@ import SwiftUI
import Utils
public struct FeedCard: View {
let viewer: Viewer?
let tapHandler: () -> Void
@ObservedObject var item: LinkedItem
public init(item: LinkedItem, tapHandler: @escaping () -> Void = {}) {
public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
self.item = item
self.viewer = viewer
self.tapHandler = tapHandler
}
@ -55,10 +57,8 @@ public struct FeedCard: View {
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.cornerRadius(6)
} else if phase.error != nil {
EmptyView().frame(width: 80, height: 80, alignment: .top)
} else {
Color.appButtonBackground
Color.systemBackground
.frame(width: 80, height: 80)
.cornerRadius(6)
}
@ -87,20 +87,11 @@ public struct FeedCard: View {
#endif
}
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
}
if let recs = Recommendation.notViewers(viewer: viewer, item.recommendations), recs.count > 0 {
let byStr = Recommendation.byline(recs)
let inStr = Recommendation.groupsLine(recs)
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

File diff suppressed because one or more lines are too long

View file

@ -46,7 +46,16 @@ private let logger = Logger(subsystem: "app.omnivore", category: "app-delegate")
}
Services.registerBackgroundFetch()
configurePushNotifications()
configureFirebase()
NotificationCenter.default.addObserver(forName: Notification.Name("ReconfigurePushNotifications"), object: nil, queue: OperationQueue.main) { _ in
if UserDefaults.standard.bool(forKey: UserDefaultKey.notificationsEnabled.rawValue) {
self.registerForNotifications()
} else {
self.unregisterForNotifications()
}
}
return true
}
}

View file

@ -8,9 +8,7 @@ import UIKit
import Utils
extension AppDelegate {
func configurePushNotifications() {
guard FeatureFlag.enablePushNotifications else { return }
func configureFirebase() {
let keys: FirebaseKeys? = {
let isProd = (PublicValet.storedAppEnvironment ?? .initialAppEnvironment) == .prod
let firebaseKeys = isProd ? AppKeys.sharedInstance?.firebaseProdKeys : AppKeys.sharedInstance?.firebaseDemoKeys
@ -30,9 +28,19 @@ extension AppDelegate {
FirebaseApp.configure(options: firebaseOpts)
FirebaseConfiguration.shared.setLoggerLevel(.min)
registerForNotifications()
}
func registerForNotifications() {
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
Messaging.messaging().delegate = self
}
func unregisterForNotifications() {
Messaging.messaging().delegate = nil
UNUserNotificationCenter.current().delegate = nil
UIApplication.shared.unregisterForRemoteNotifications()
}
}
@ -43,6 +51,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
guard UserDefaults.standard.bool(forKey: UserDefaultKey.notificationsEnabled.rawValue) else { return }
let userInfo = notification.request.content.userInfo
UIApplication.shared.applicationIconBadgeNumber = 0
print(userInfo) // extract data sent along with PN
@ -54,6 +64,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
guard UserDefaults.standard.bool(forKey: UserDefaultKey.notificationsEnabled.rawValue) else { return }
let userInfo = response.notification.request.content.userInfo
if let linkData = userInfo["link"] as? String {
@ -87,12 +99,22 @@ extension AppDelegate: MessagingDelegate {
guard let fcmToken = fcmToken else { return }
let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue)
let deviceTokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue)
if savedToken == fcmToken {
if !UserDefaults.standard.bool(forKey: UserDefaultKey.notificationsEnabled.rawValue) {
// save the token for use later if needed and return so it is not uploaded
UserDefaults.standard.set(fcmToken, forKey: UserDefaultKey.firebasePushToken.rawValue)
return
}
UserDefaults.standard.set(fcmToken, forKey: UserDefaultKey.firebasePushToken.rawValue)
Services().dataService.syncDeviceToken(deviceTokenOperation: .addToken(token: fcmToken))
// If the deviceTokenID is null, that means we haven't set our token yet, and this is just
// a previously saved token.
if savedToken == fcmToken, deviceTokenID != nil {
return
}
Task {
try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .addToken(token: fcmToken))
}
}
}

View file

@ -5,18 +5,24 @@ type AvatarProps = {
imageURL?: string
height: string
fallbackText: string
tooltip?: string
noFade?: boolean
}
export function Avatar(props: AvatarProps): JSX.Element {
return (
<StyledAvatar
title={props.tooltip}
css={{
width: props.height,
height: props.height,
borderRadius: '50%',
}}
>
<StyledImage src={props.imageURL} />
<StyledImage
src={props.imageURL}
css={{ opacity: props.noFade ? 'unset' : '48%' }}
/>
<StyledFallback>{props.fallbackText}</StyledFallback>
</StyledAvatar>
)
@ -36,7 +42,6 @@ const StyledImage = styled(Image, {
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: '48%',
'&:hover': {
opacity: '100%',

View file

@ -22,6 +22,28 @@ const textVariants = {
fontSize: '$2',
lineHeight: '1.25',
},
recommendedByline: {
fontWeight: 'bold',
fontSize: '13.5px',
paddingTop: '4px',
mt: '0px',
mb: '16px',
color: '$grayTextContrast',
},
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 { StyledText } from './../../elements/StyledText'
import {
Blockquote,
Box,
HStack,
SpanBox,
VStack,
} from './../../elements/LayoutPrimitives'
import { StyledText, StyledTextSpan } 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
@ -37,16 +53,86 @@ type ArticleContainerProps = {
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
}
type RecommendationCommentsProps = {
recommendationsWithNotes: Recommendation[]
}
const RecommendationComments = (
props: RecommendationCommentsProps
): JSX.Element => {
return (
<VStack
id="recommendations-container"
css={{
borderRadius: '6px',
bg: '$grayBgSubtle',
p: '16px',
pt: '16px',
pb: '2px',
width: '100%',
marginTop: '24px',
color: '$grayText',
lineHeight: '2.0',
}}
>
<HStack css={{ pb: '0px', mb: '0px' }}>
<StyledText
style="recommendedByline"
css={{ paddingTop: '0px', mb: '16px' }}
>
Comments{' '}
<SpanBox css={{ color: 'grayText', fontWeight: '400' }}>
&nbsp;{` ${props.recommendationsWithNotes.length}`}
</SpanBox>
</StyledText>
</HStack>
{props.recommendationsWithNotes.map((item, idx) => (
<VStack
key={item.id}
alignment="start"
distribution="start"
css={{ pt: '0px', pb: '8px' }}
>
<HStack>
<SpanBox
css={{
verticalAlign: 'top',
minWidth: '28px',
display: 'flex',
}}
>
<Avatar
imageURL={item.user?.profileImageURL}
height="28px"
noFade={true}
tooltip={item.user?.name}
fallbackText={item.user?.username[0] ?? 'U'}
/>
</SpanBox>
<StyledText style="userNote" css={{ pl: '16px' }}>
{item.note}
</StyledText>
</HStack>
</VStack>
))}
</VStack>
)
}
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 +282,14 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
readerHeadersColor: theme.colors.readerHeader.toString(),
}
const recommendationsWithNotes = useMemo(() => {
return (
props.article.recommendations?.filter((recommendation) => {
return recommendation.note
}) ?? []
)
}, [props.article.recommendations])
return (
<>
<Box
@ -266,6 +360,11 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
))}
</SpanBox>
) : null}
{recommendationsWithNotes.length > 0 && (
<RecommendationComments
recommendationsWithNotes={recommendationsWithNotes}
/>
)}
</VStack>
<Article
articleId={props.article.id}

View file

@ -7,7 +7,7 @@ export enum ThemeId {
Dark = 'Gray',
Darker = 'Dark',
Sepia = 'Sepia',
Charcoal = 'Charcoal'
Charcoal = 'Charcoal',
}
export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
@ -132,6 +132,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
// Semantic Colors
highlightBackground: '250, 227, 146',
recommendedHighlightBackground: '#E5FFE5',
highlight: '#FFD234',
highlightText: '#3D3D3D',
error: '#FA5E4A',
@ -165,7 +166,6 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
libraryActiveMenuItem: '#F8F8F8',
border: '#F0F0F0',
//utility
textNonEssential: 'rgba(10, 8, 6, 0.4)',
overlay: 'rgba(63, 62, 60, 0.2)',
@ -205,6 +205,7 @@ const darkThemeSpec = {
// Semantic Colors
highlightBackground: '134, 119, 64',
recommendedHighlightBackground: '#1F4315',
highlight: '#FFD234',
highlightText: 'white',
error: '#FA5E4A',
@ -245,7 +246,7 @@ const sepiaThemeSpec = {
readerFontHighContrast: 'black',
readerHeader: '554A34',
readerTableHeader: '#FFFFFF',
}
},
}
const charcoalThemeSpec = {
@ -256,16 +257,21 @@ const charcoalThemeSpec = {
readerFontHighContrast: 'white',
readerHeader: '#b9b9b9',
readerTableHeader: '#FFFFFF',
}
},
}
// Dark and Darker theme now match each other.
// Use the darkThemeSpec object to make updates.
export const darkTheme = createTheme(ThemeId.Dark, darkThemeSpec)
export const darkerTheme = createTheme(ThemeId.Darker, darkThemeSpec)
export const sepiaTheme = createTheme(ThemeId.Sepia, {...darkThemeSpec, ...sepiaThemeSpec})
export const charcoalTheme = createTheme(ThemeId.Charcoal, {...darkThemeSpec, ...charcoalThemeSpec})
export const sepiaTheme = createTheme(ThemeId.Sepia, {
...darkThemeSpec,
...sepiaThemeSpec,
})
export const charcoalTheme = createTheme(ThemeId.Charcoal, {
...darkThemeSpec,
...charcoalThemeSpec,
})
// Lighter theme now matches the default theme.
// This only exists for users that might still have a lighter theme set
@ -273,8 +279,8 @@ export const lighterTheme = createTheme(ThemeId.Lighter, {})
// Apply global styles in here
export const globalStyles = globalCss({
'body': {
backgroundColor: '$grayBase'
body: {
backgroundColor: '$grayBase',
},
'*': {
'&:focus': {

View file

@ -67,16 +67,10 @@ function nodeAttributesFromHighlight(
const patch = highlight.patch
const id = highlight.id
const withNote = !!highlight.annotation
const customColor = undefined
const tooltip = undefined
// We've disabled shared highlights, so passing undefined
// here now, and removing the user object from highlights
// !highlight.createdByMe
// ? stringToColour(highlight.user.profile.username)
// : undefined
// const tooltip = !highlight.createdByMe
// ? `Created by: @${highlight.user.profile.username}`
// : undefined
const customColor = highlight.createdByMe
? undefined
: 'var(--colors-recommendedHighlightBackground)'
return makeHighlightNodeAttributes(patch, id, withNote, customColor, tooltip)
}

View file

@ -184,6 +184,9 @@ async function makeSelectionRange(): Promise<
}
const articleContentElement = document.getElementById('article-container')
const recommendationsElement = document.getElementById(
'recommendations-container'
)
if (!articleContentElement)
throw new Error('Unable to find the article content element')
@ -193,6 +196,11 @@ async function makeSelectionRange(): Promise<
const range = selection.getRangeAt(0)
if (recommendationsElement && range.intersectsNode(recommendationsElement)) {
console.log('attempt to highlight in recommendations area')
return undefined
}
const start = range.compareBoundaryPoints(Range.START_TO_START, allowedRange)
const end = range.compareBoundaryPoints(Range.END_TO_END, allowedRange)
const isRangeAllowed = start >= 0 && end <= 0

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,