mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1522 from omnivore-app/feat/ios-recommendations
More on iOS recommendations
This commit is contained in:
commit
fa659bdf66
53 changed files with 1840 additions and 318 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ struct WebReaderContent {
|
|||
readingProgressAnchorIndex: \(item.readingProgressAnchor),
|
||||
labels: \(item.labelsJSONString),
|
||||
highlights: \(articleContent.highlightsJSONString),
|
||||
recommendations: \(item.recommendationsJSONString),
|
||||
}
|
||||
|
||||
window.fontSize = \(textFontSize)
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ extension DataService {
|
|||
createdAt: nil,
|
||||
updatedAt: nil,
|
||||
createdByMe: true,
|
||||
createdBy: nil,
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ extension DataService {
|
|||
createdAt: nil,
|
||||
updatedAt: nil,
|
||||
createdByMe: true,
|
||||
createdBy: nil,
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
public struct InternalDeviceToken: Identifiable {
|
||||
public let id: String
|
||||
public let createdAt: Date?
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,4 +23,7 @@ public enum UserDefaultKey: String {
|
|||
case recentSearchTerms
|
||||
case audioPlayerExpanded
|
||||
case themeName
|
||||
case shouldShowNewFeaturePrimer
|
||||
case notificationsEnabled
|
||||
case deviceTokenID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift
Normal file
69
apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift
Normal 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)
|
||||
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
{` ${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}
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue