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

iOS recommendation groups
This commit is contained in:
Jackson Harper 2022-12-07 14:16:52 +08:00 committed by GitHub
commit 7b2549c6e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 4044 additions and 157 deletions

View file

@ -1307,7 +1307,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 77;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1317,7 +1317,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.18.0;
MARKETING_VERSION = 1.22.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
@ -1339,7 +1339,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 77;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1349,7 +1349,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.18.0;
MARKETING_VERSION = 1.22.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1421,7 +1421,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 77;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1432,7 +1432,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.18.0;
MARKETING_VERSION = 1.22.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1455,7 +1455,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 77;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1466,7 +1466,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.18.0;
MARKETING_VERSION = 1.22.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1521,7 +1521,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.21.0;
MARKETING_VERSION = 1.22.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1600,7 +1600,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.21.0;
MARKETING_VERSION = 1.22.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1639,7 +1639,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.21.0;
MARKETING_VERSION = 1.22.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1665,7 +1665,7 @@
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1678,7 +1678,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.18.0;
MARKETING_VERSION = 1.22.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1704,7 +1704,7 @@
CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 77;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1717,7 +1717,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.18.0;
MARKETING_VERSION = 1.22.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1804,7 +1804,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.21.0;
MARKETING_VERSION = 1.22.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1859,7 +1859,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.21.0;
MARKETING_VERSION = 1.22.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1888,7 +1888,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.21.0;
MARKETING_VERSION = 1.22.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -355,42 +355,6 @@
}
}
var changeVoiceView: some View {
NavigationView {
VStack {
List {
ForEach(audioController.voiceList ?? [], id: \.key.self) { voice in
Button(action: {
audioController.currentVoice = voice.key
self.showVoiceSheet = false
}, label: {
HStack {
Text(voice.name)
Spacer()
if voice.selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
}).buttonStyle(PlainButtonStyle())
}
}
.padding(.top, 32)
.listStyle(.plain)
Spacer()
}
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
}))
}
}
var scrubbing: Bool {
switch audioController.scrubState {
case .scrubStarted:

View file

@ -97,6 +97,10 @@ struct ProfileView: View {
NavigationLink(destination: SubscriptionsView()) {
Text("Subscriptions")
}
NavigationLink(destination: GroupsView()) {
Text("Recommendation Groups")
}
}
#if os(iOS)

View file

@ -0,0 +1,146 @@
import Models
import Services
import SwiftUI
import Views
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = true
@Published var recommendationGroup: InternalRecommendationGroup
init(recommendationGroup: InternalRecommendationGroup) {
self.recommendationGroup = recommendationGroup
}
var nonAdmins: [InternalUserProfile] {
recommendationGroup.members.filter { member in
!recommendationGroup.admins.contains(where: { member.id == $0.id })
}
}
}
private struct SmallUserCard: View {
let data: ProfileCardData
var size: CGFloat
public init(data: ProfileCardData, size: CGFloat = 36) {
self.data = data
self.size = size
}
public var body: some View {
HStack(alignment: .center, spacing: 16) {
Group {
AsyncImage(
url: data.imageURL,
content: { $0.resizable() },
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayTextContrast)
}
)
}
.padding(.leading, 0)
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size, alignment: .center)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text(data.name)
.font(.appBody)
.foregroundColor(.appGrayTextContrast)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("@\(data.username)")
.font(.appCaption)
.foregroundColor(.appGrayText)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity)
.multilineTextAlignment(.leading)
.padding(0)
}
.frame(maxWidth: .infinity)
}
}
struct RecommendationGroupView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel: RecommendationsGroupViewModel
@State var presentShareSheet = false
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
}
private var shareView: some View {
if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) {
return AnyView(ShareSheet(activityItems: [shareLink]))
} else {
return AnyView(Text("Error copying invite URL"))
}
}
private var adminsSection: some View {
Section("Admins") {
ForEach(viewModel.recommendationGroup.admins) { admin in
SmallUserCard(data: ProfileCardData(
name: admin.name,
username: admin.username,
imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil
))
}
}
}
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
))
}
}
}
private var innerBody: some View {
Group {
Section("Name") {
Text(viewModel.recommendationGroup.name)
}
Section("Invite Link") {
Button(action: {
presentShareSheet = true
}, label: {
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
.font(.appCaption)
})
}
adminsSection
membersSection
}
.formSheet(isPresented: $presentShareSheet) {
shareView
}
.navigationTitle(viewModel.recommendationGroup.name)
}
}

View file

@ -0,0 +1,145 @@
import Models
import Services
import SwiftUI
import Views
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
@Published var isLoading = false
@Published var isCreating = false
@Published var networkError = true
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var showCreateSheet = false
@Published var showCreateError = false
@Published var createGroupError: String?
func loadGroups(dataService: DataService) async {
isLoading = true
do {
recommendationGroups = try await dataService.recommendationGroups()
} catch {
networkError = true
}
isLoading = false
}
func createGroup(dataService: DataService, name: String) async {
isCreating = true
if let group = try? await dataService.createRecommendationGroup(name: name) {
print("CREATED GROUP: ", group)
await loadGroups(dataService: dataService)
showCreateSheet = false
} else {
createGroupError = "Error creating group"
showCreateError = true
}
isCreating = false
}
}
struct CreateRecommendationGroupView: View {
@State var name = ""
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = RecommendationsGroupsViewModel()
var nextButton: some View {
if viewModel.isCreating {
return AnyView(ProgressView())
} else {
return AnyView(Button(action: {
Task {
await viewModel.createGroup(dataService: dataService, name: self.name)
}
}, label: {
Text("Next")
})
.disabled(name.isEmpty)
)
}
}
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name, prompt: Text("Group Name"))
}
.alert(isPresented: $viewModel.showCreateError) {
Alert(
title: Text(viewModel.createGroupError ?? "Error creating group"),
dismissButton: .cancel(Text("Ok")) {
viewModel.createGroupError = nil
viewModel.showCreateError = false
}
)
}
}
.navigationViewStyle(.stack)
.navigationTitle("Create Group")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading:
Button(action: {
viewModel.showCreateSheet = false
}, label: { Text("Cancel") }),
trailing: nextButton)
}
}
struct GroupsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = RecommendationsGroupsViewModel()
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.sheet(isPresented: $viewModel.showCreateSheet) {
NavigationView {
CreateRecommendationGroupView(viewModel: self.viewModel)
}
}
.task { await viewModel.loadGroups(dataService: dataService) }
}
private var innerBody: some View {
Group {
Section {
Button(
action: { viewModel.showCreateSheet = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text("Create a new group")
Spacer()
}
}
)
.disabled(viewModel.isLoading)
}
Section(header: Text("Your recommendation groups")) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
NavigationLink(
destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup))
) {
Text(recommendationGroup.name)
}
}
}
}
.navigationTitle("Recommendation Groups")
}
}

View file

@ -6,7 +6,7 @@ import Utils
import Views
@MainActor final class CreateProfileViewModel: ObservableObject {
private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil)
private(set) var initialUserProfile = NewUserProfile(username: "", name: "", bio: nil)
var isConfigured = false
var hasSuggestedProfile: Bool {
@ -31,7 +31,7 @@ import Views
init() {}
func submitProfile(name: String, bio: String, authenticator: Authenticator) {
let profileOrError = UserProfile.make(
let profileOrError = NewUserProfile.make(
username: potentialUsername,
name: name,
bio: bio.isEmpty ? nil : bio
@ -77,7 +77,7 @@ import Views
}
}
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async {
func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async {
do {
try await authenticator.createAccount(userProfile: userProfile)
} catch {
@ -87,7 +87,7 @@ import Views
}
}
func configure(profile: UserProfile, dataService: DataService) {
func configure(profile: NewUserProfile, dataService: DataService) {
guard !isConfigured else { return }
isConfigured = true
@ -104,7 +104,7 @@ import Views
}
struct CreateProfileView: View {
private let initialUserProfile: UserProfile
private let initialUserProfile: NewUserProfile
@State private var isConfigured = false
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@EnvironmentObject var authenticator: Authenticator
@ -114,7 +114,7 @@ struct CreateProfileView: View {
@State private var name = ""
@State private var bio = ""
init(userProfile: UserProfile) {
init(userProfile: NewUserProfile) {
self.initialUserProfile = userProfile
}

View file

@ -9,7 +9,7 @@ import Views
init() {}
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async {
func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async {
do {
try await authenticator.createAccount(userProfile: userProfile)
} catch {
@ -23,7 +23,7 @@ import Views
struct NewAppleSignupView: View {
@EnvironmentObject var authenticator: Authenticator
@StateObject private var viewModel = NewAppleSignupViewModel()
let userProfile: UserProfile
let userProfile: NewUserProfile
let showProfileEditView: () -> Void
var body: some View {

View file

@ -7,8 +7,8 @@ import Views
@MainActor final class RegistrationViewModel: ObservableObject {
enum RegistrationState {
case createProfile(userProfile: UserProfile)
case newAppleSignUp(userProfile: UserProfile)
case createProfile(userProfile: NewUserProfile)
case newAppleSignUp(userProfile: NewUserProfile)
}
@Published var loginError: LoginError?
@ -65,7 +65,7 @@ import Views
case let .loginError(error):
loginError = error
case .newOmnivoreUser:
registrationState = .createProfile(userProfile: UserProfile(username: "", name: ""))
registrationState = .createProfile(userProfile: NewUserProfile(username: "", name: ""))
case .existingOmnivoreUser:
break
}

View file

@ -0,0 +1,119 @@
import Models
import Services
import SwiftUI
import Views
@MainActor final class RecommendToViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = true
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var selectedGroups = [String]()
@Published var isRunning = false
@Published var showError = false
@Published var note: String?
let pageID: String
init(pageID: String) {
self.pageID = pageID
}
func loadGroups(dataService: DataService) async {
isLoading = true
do {
recommendationGroups = try await dataService.recommendationGroups()
} catch {
print("ERROR fetching recommendationGroups: ", error)
networkError = true
}
isLoading = false
}
func recommend(dataService: DataService) async {
isRunning = true
do {
try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups, note: note)
} catch {
showError = true
}
isRunning = false
}
}
struct RecommendToView: View {
var dataService: DataService
@StateObject var viewModel: RecommendToViewModel
@Environment(\.dismiss) private var dismiss
var nextButton: 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()
}
}, label: {
Text("Next")
})
.disabled(viewModel.selectedGroups.isEmpty)
)
}
}
var body: some View {
VStack {
List {
Section("Select groups to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)
Spacer()
if viewModel.selectedGroups.contains(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)
}
}
}
}
}
.listStyle(.grouped)
Spacer()
}
.alert(isPresented: $viewModel.showError) {
Alert(
title: Text("Error recommending this page"),
dismissButton: .cancel(Text("Ok")) {
viewModel.showError = false
}
)
}
.navigationBarTitle("Recommend")
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(leading: Button(action: {
dismiss()
}, label: { Text("Cancel") }),
trailing: nextButton)
.task {
await viewModel.loadGroups(dataService: dataService)
}
}
}

View file

@ -29,6 +29,7 @@ struct WebReaderContainerView: View {
@State private var bottomBarOpacity = 0.0
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@State private var showRecommendSheet = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ -150,8 +151,8 @@ struct WebReaderContainerView: View {
}).frame(width: 48, height: 48)
Divider().opacity(0.8)
Button(action: share, label: {
Image(systemName: "square.and.arrow.up")
Button(action: recommend, label: {
Image(systemName: "sparkles")
}).frame(width: 48, height: 48)
// TODO: We don't have a single note function yet
@ -219,6 +220,12 @@ struct WebReaderContainerView: View {
action: delete,
label: { Label("Delete", systemImage: "trash") }
)
Button(
action: {
showRecommendSheet = true
},
label: { Label("Recommend", systemImage: "sparkles") }
)
}
}
@ -351,6 +358,16 @@ struct WebReaderContainerView: View {
showErrorAlertMessage = false
})
}
.formSheet(isPresented: $showRecommendSheet) {
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID)
)
}.onDisappear {
showRecommendSheet = false
}
}
.sheet(isPresented: $showHighlightAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
@ -440,6 +457,10 @@ struct WebReaderContainerView: View {
Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox")
}
func recommend() {
showRecommendSheet = true
}
func share() {
shareActionID = UUID()
}

View file

@ -62,7 +62,7 @@ extension WebReaderCoordinator: WKNavigationDelegate {
// swiftlint:disable:next line_length
func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
if let linkURL = navigationAction.request.url {
if let linkURL = navigationAction.request.url, !linkURL.isFileURL {
linkHandler(linkURL)
}
decisionHandler(.cancel)

View file

@ -55,6 +55,7 @@
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkedItem" inverseEntity="Recommendation"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
@ -91,6 +92,28 @@
<attribute name="savedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<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="name" 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="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
</entity>
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="inviteUrl" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="admins" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Viewer"/>
<relationship name="members" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Viewer"/>
</entity>
<entity name="UserProfile" representedClassName="UserProfile" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="profileImageURL" optional="YES" attributeType="String"/>
<attribute name="userID" optional="YES" attributeType="String"/>
<attribute name="username" optional="YES" attributeType="String"/>
</entity>
<entity name="Viewer" representedClassName="Viewer" syncable="YES" codeGenerationType="class">
<attribute name="name" attributeType="String"/>
<attribute name="profileImageURL" optional="YES" attributeType="String"/>

View file

@ -21,6 +21,7 @@ public class PersistentContainer: NSPersistentContainer {
#endif
let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite")
print("starting with sqlite container", appGroupContainerURL)
container.persistentStoreDescriptions.first!.url = appGroupContainerURL

View file

@ -0,0 +1,52 @@
import Foundation
import Utils
public struct NewUserProfile: Codable {
public let username: String
public let name: String
public let bio: String?
public init(
username: String,
name: String,
bio: String? = nil
) {
self.username = username
self.name = name
self.bio = bio
}
}
public extension NewUserProfile {
static func make(
username: String,
name: String,
bio: String?
) -> Either<NewUserProfile, String> {
let userProfile = NewUserProfile(
username: username,
name: name,
bio: bio
)
if let errorMessage = userProfile.validationErrorMessage {
return .right(errorMessage)
} else {
return .left(userProfile)
}
}
private var validationErrorMessage: String? {
if name.isEmpty {
return "The name field is missing."
}
return nil
}
}
public extension Viewer {
var unwrappedUsername: String { username ?? "" }
var unwrappedName: String { name ?? "" }
var unwrappedUserID: String { userID ?? "" }
}

View file

@ -0,0 +1,21 @@
import CoreData
import Foundation
public extension Recommendation {
var unwrappedID: String { id ?? "" }
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
}
return recommendation
}
}

View file

@ -1,52 +1,19 @@
import CoreData
import Foundation
import Utils
public struct UserProfile: Codable {
public let username: String
public let name: String
public let bio: String?
public init(
username: String,
name: String,
bio: String? = nil
) {
self.username = username
self.name = name
self.bio = bio
}
}
public extension UserProfile {
static func make(
username: String,
name: String,
bio: String?
) -> Either<UserProfile, String> {
let userProfile = UserProfile(
username: username,
name: name,
bio: bio
static func lookup(byID userID: String, inContext context: NSManagedObjectContext) -> UserProfile? {
let fetchRequest: NSFetchRequest<Models.UserProfile> = UserProfile.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "%K == %@", #keyPath(UserProfile.userID), userID
)
if let errorMessage = userProfile.validationErrorMessage {
return .right(errorMessage)
} else {
return .left(userProfile)
}
}
var result: UserProfile?
private var validationErrorMessage: String? {
if name.isEmpty {
return "The name field is missing."
context.performAndWait {
result = (try? context.fetch(fetchRequest))?.first
}
return nil
return result
}
}
public extension Viewer {
var unwrappedUsername: String { username ?? "" }
var unwrappedName: String { name ?? "" }
var unwrappedUserID: String { userID ?? "" }
}

View file

@ -5,12 +5,12 @@ public extension Authenticator {
func createPendingAccountUsingApple(
token: String,
name: PersonNameComponents?
) async throws -> UserProfile {
) async throws -> NewUserProfile {
let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name)
return try await createPendingAccount(params: params)
}
func createAccount(userProfile: UserProfile) async throws {
func createAccount(userProfile: NewUserProfile) async throws {
let params = CreateAccountParams(
pendingUserToken: pendingUserToken ?? "",
userProfile: userProfile
@ -34,7 +34,7 @@ public extension Authenticator {
}
extension Authenticator {
func createPendingAccount(params: CreatePendingAccountParams) async throws -> UserProfile {
func createPendingAccount(params: CreatePendingAccountParams) async throws -> NewUserProfile {
do {
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams)

View file

@ -48,12 +48,12 @@ struct EmailAuthPayload: Decodable {
struct CreateAccountParams: Encodable {
let pendingUserToken: String
let userProfile: UserProfile
let userProfile: NewUserProfile
}
struct PendingUserAuthPayload: Decodable {
let pendingUserToken: String
let pendingUserProfile: UserProfile
let pendingUserProfile: NewUserProfile
}
struct PendingEmailVerificationAuthPayload: Decodable {

View file

@ -48,6 +48,7 @@ extension DataService {
do {
objectID = try await persistArticleContent(articleProps: fetchResult)
} catch {
print("caught article content error: ", error)
var message = "unknown error"
let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error")
if case let BasicError.message(messageText) = basicError {
@ -230,7 +231,7 @@ extension DataService {
} catch {
// We don't propogate these errors, we just let it pass through so
// the user can attempt to fetch content again.
print("Error syncUnsyncedArticleContent")
print("Error syncUnsyncedArticleContent", error)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createRecommendationGroup(name: String) async throws -> InternalRecommendationGroup {
enum MutationResult {
case saved(recommendationGroup: InternalRecommendationGroup)
case error(errorCode: Enums.CreateGroupErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateGroupResult> {
try $0.on(
createGroupError: .init {
.error(errorCode: try $0.errorCodes().first ?? .badRequest)
},
createGroupSuccess: .init {
.saved(recommendationGroup: try $0.group(selection: recommendationGroupSelection))
}
)
}
let input = InputObjects.CreateGroupInput(name: name)
let mutation = Selection.Mutation {
try $0.createGroup(input: input, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { [weak self] queryResult in
guard let payload = try? queryResult.get(), let self = self else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .saved(recommendationGroup: recommendationGroup):
if [recommendationGroup].persist(context: self.backgroundContext) != nil {
continuation.resume(returning: recommendationGroup)
} else {
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
}
case let .error(errorCode: errorCode):
continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue))
}
}
}
}
}

View file

@ -0,0 +1,48 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func recommendPage(pageID: String, groupIDs: [String], note: String?) async throws {
enum MutationResult {
case saved(taskNames: [String])
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.RecommendResult> {
try $0.on(
recommendError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) },
recommendSuccess: .init {
.saved(taskNames: try $0.taskNames())
}
)
}
let mutation = Selection.Mutation {
try $0.recommend(
input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID),
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 .saved:
continuation.resume()
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
}
}
}
}
}

View file

@ -72,6 +72,7 @@ extension SaveArticleError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
print("HTTP ERROR", httpError)
return .unknown(description: httpError.localizedDescription)
}
}

View file

@ -44,6 +44,7 @@ extension DataService {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
),
htmlContent: try $0.content(),

View file

@ -224,6 +224,40 @@ 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
}
}
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 []
}
private let libraryArticleSelection = Selection.Article {
InternalLinkedItem(
id: try $0.id(),
@ -249,6 +283,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(),
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
@ -286,6 +321,7 @@ private let searchItemSelection = Selection.SearchItem {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}

View file

@ -0,0 +1,52 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func recommendationGroups() async throws -> [InternalRecommendationGroup] {
enum QueryResult {
case success(result: [InternalRecommendationGroup])
case error(error: String)
}
let selection = Selection<QueryResult, Unions.GroupsResult> {
try $0.on(
groupsError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
groupsSuccess: .init {
QueryResult.success(result: try $0.groups(selection: recommendationGroupSelection.list))
}
)
}
let query = Selection.Query {
try $0.groups(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):
if result.persist(context: context) != nil {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
}
case .error:
continuation.resume(throwing: BasicError.message(messageText: "Recommendation Groups fetch error"))
}
}
}
}
}

View file

@ -0,0 +1,12 @@
import Models
import SwiftGraphQL
let recommendationGroupSelection = Selection.RecommendationGroup {
InternalRecommendationGroup(
id: try $0.id(),
name: try $0.name(),
inviteUrl: try $0.inviteUrl(),
admins: try $0.admins(selection: userProfileSelection.list),
members: try $0.members(selection: userProfileSelection.list)
)
}

View file

@ -0,0 +1,18 @@
import Models
import SwiftGraphQL
let profileSelection = Selection.Profile {
(
username: try $0.username(),
profileImageURL: try $0.pictureUrl()
)
}
let userProfileSelection = Selection.User {
InternalUserProfile(
userID: try $0.id(),
name: try $0.name(),
username: try $0.profile(selection: profileSelection).username,
profileImageURL: try $0.profile(selection: profileSelection).profileImageURL
)
}

View file

@ -26,6 +26,7 @@ struct InternalLinkedItem {
let contentReader: String?
let originalHtml: String?
let language: String?
let recommendations: [InternalRecommendation]
var labels: [InternalLinkedItemLabel]
var isPDF: Bool {
@ -72,6 +73,14 @@ struct InternalLinkedItem {
linkedItem.addToLabels(label.asManagedObject(inContext: context))
}
if let existingRecommendation = linkedItem.recommendations {
linkedItem.removeFromRecommendations(existingRecommendation)
}
for recommendation in recommendations {
linkedItem.addToRecommendations(recommendation.asManagedObject(inContext: context))
}
return linkedItem
}
}
@ -133,6 +142,7 @@ extension JSONArticle {
contentReader: contentReader,
originalHtml: nil,
language: language,
recommendations: [], // TODO:
labels: []
)

View file

@ -0,0 +1,39 @@
import CoreData
import Foundation
import Models
public struct InternalRecommendation {
let id: String
let name: 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
recommendation.name = name
recommendation.recommendedAt = recommendedAt
recommendation.user = user?.asManagedObject(inContext: context)
return recommendation
}
public static func make(_ recommendations: NSSet?) -> [InternalRecommendation] {
recommendations?
.compactMap { recommendation in
if let recommendation = recommendation as? Recommendation,
let id = recommendation.id,
let name = recommendation.name,
let recommendedAt = recommendation.recommendedAt
{
return InternalRecommendation(
id: id,
name: name,
user: InternalUserProfile.makeSingle(recommendation.user),
recommendedAt: recommendedAt
)
}
return nil
} ?? []
}
}

View file

@ -0,0 +1,69 @@
//
// InternalRecommendationGroup.swift
//
//
// Created by Jackson Harper on 12/5/22.
//
import CoreData
import Foundation
import Models
public struct InternalRecommendationGroup: Identifiable {
public let id: String
public let name: String
public let inviteUrl: String
public let admins: [InternalUserProfile]
public let members: [InternalUserProfile]
func asManagedObject(inContext context: NSManagedObjectContext) -> RecommendationGroup {
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "id == %@", id
)
let existing = (try? context.fetch(fetchRequest))?.first
let recommendationGroup = existing ?? RecommendationGroup(entity: RecommendationGroup.entity(), insertInto: context)
recommendationGroup.id = id
recommendationGroup.name = name
recommendationGroup.inviteUrl = inviteUrl
return recommendationGroup
}
static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? {
if let id = recommendationGroup.id,
let name = recommendationGroup.name,
let inviteUrl = recommendationGroup.inviteUrl
{
return InternalRecommendationGroup(
id: id,
name: name,
inviteUrl: inviteUrl,
admins: InternalUserProfile.make(recommendationGroup.admins),
members: InternalUserProfile.make(recommendationGroup.members)
)
}
return nil
}
}
extension Sequence where Element == InternalRecommendationGroup {
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
var result: [NSManagedObjectID]?
context.performAndWait {
let recommendationGroups = map { $0.asManagedObject(inContext: context) }
do {
try context.save()
logger.debug("InternalRecommendationGroup saved succesfully")
result = recommendationGroups.map(\.objectID)
} catch {
context.rollback()
logger.debug("Failed to save InternalRecommendationGroups: \(error.localizedDescription)")
}
}
return result
}
}

View file

@ -0,0 +1,79 @@
import CoreData
import Foundation
import Models
public struct InternalUserProfile: Identifiable {
let userID: String
public let name: String
public let username: String
public let profileImageURL: String?
public var id: String {
userID
}
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
var objectID: NSManagedObjectID?
context.performAndWait {
let user = asManagedObject(inContext: context)
do {
try context.save()
logger.debug("User saved succesfully")
objectID = user.objectID
} catch {
context.rollback()
logger.debug("Failed to save User: \(error.localizedDescription)")
}
}
return objectID
}
func asManagedObject(inContext context: NSManagedObjectContext) -> UserProfile {
let existing = UserProfile.lookup(byID: userID, inContext: context)
let userProfile = existing ?? UserProfile(entity: UserProfile.entity(), insertInto: context)
userProfile.userID = userID
userProfile.name = name
userProfile.username = username
userProfile.profileImageURL = profileImageURL
return userProfile
}
public static func makeSingle(_ user: UserProfile?) -> InternalUserProfile? {
if let user = user,
let userID = user.userID,
let name = user.name,
let username = user.username
{
return InternalUserProfile(
userID: userID,
name: name,
username: username,
profileImageURL: user.profileImageURL
)
}
return nil
}
public static func make(_ users: NSSet?) -> [InternalUserProfile] {
users?
.compactMap { user in
if let user = user as? UserProfile,
let userID = user.userID,
let name = user.name,
let username = user.username
{
return InternalUserProfile(
userID: userID,
name: name,
username: username,
profileImageURL: user.profileImageURL
)
}
return nil
} ?? []
}
}

View file

@ -86,6 +86,27 @@ 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
}
HStack {
Text("Recommended by \(byStr) in \(inStr)")
.font(.appCaption)
.frame(alignment: .leading)
Spacer()
}
}
}
.padding(.top, 0)
.padding(.bottom, 8)

View file

@ -1,9 +1,9 @@
import SwiftUI
public struct ProfileCardData {
let name: String
let username: String
let imageURL: URL?
public let name: String
public let username: String
public let imageURL: URL?
public init(name: String = "", username: String = "", imageURL: URL? = nil) {
self.name = name

View file

@ -189,10 +189,10 @@ const appendIdsFilter = (body: SearchBody, ids: string[]): void => {
const appendRecommendedBy = (body: SearchBody, recommendedBy: string): void => {
body.query.bool.must.push({
nested: {
path: 'recommendedBy',
path: 'recommendations',
query: {
term: {
'recommendedBy.name': recommendedBy,
'recommendations.name': recommendedBy,
},
},
},

View file

@ -13,19 +13,19 @@ export const addRecommendation = async (
url: page.url,
})
if (existingPage) {
if (existingPage.recommendedBy?.includes(recommendation)) {
if (existingPage.recommendations?.includes(recommendation)) {
return existingPage._id
}
// update recommendedBy in the existing page
const recommendedBy = (existingPage.recommendedBy || []).concat(
const recommendations = (existingPage.recommendations || []).concat(
recommendation
)
await updatePage(
existingPage.id,
{
recommendedBy,
recommendations,
},
ctx
)
@ -36,7 +36,7 @@ export const addRecommendation = async (
const newPage: Page = {
...page,
id: '',
recommendedBy: [recommendation],
recommendations: [recommendation],
userId: ctx.uid,
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,

View file

@ -53,10 +53,10 @@ export interface SearchBody {
}
| {
nested: {
path: 'recommendedBy'
path: 'recommendations'
query: {
term: {
'recommendedBy.name': string
'recommendations.name': string
}
}
}
@ -198,9 +198,18 @@ export interface Highlight {
highlightPositionAnchorIndex?: number | null
}
export interface RecommendingUser {
userId: string
name: string
username: string
profileImageURL: string | null
}
export interface Recommendation {
id: string
name: string
note: string | null
user: RecommendingUser
recommendedAt: Date
}
@ -240,7 +249,7 @@ export interface Page {
readAt?: Date
listenedAt?: Date
wordsCount?: number
recommendedBy?: Recommendation[]
recommendations?: Recommendation[]
}
export interface SearchItem {
@ -273,7 +282,7 @@ export interface SearchItem {
wordsCount?: number
siteName?: string
siteIcon?: string
recommendedBy?: Recommendation[]
recommendations?: Recommendation[]
}
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const

View file

@ -110,7 +110,7 @@ export type Article = {
readAt?: Maybe<Scalars['Date']>;
readingProgressAnchorIndex: Scalars['Int'];
readingProgressPercent: Scalars['Float'];
recommendedBy?: Maybe<Array<Recommendation>>;
recommendations?: Maybe<Array<Recommendation>>;
savedAt: Scalars['Date'];
savedByViewer?: Maybe<Scalars['Boolean']>;
shareInfo?: Maybe<LinkShareInfo>;
@ -1787,6 +1787,7 @@ export enum RecommendErrorCode {
export type RecommendInput = {
groupIds: Array<Scalars['ID']>;
note?: InputMaybe<Scalars['String']>;
pageId: Scalars['ID'];
};
@ -1801,7 +1802,9 @@ export type Recommendation = {
__typename?: 'Recommendation';
id: Scalars['ID'];
name: Scalars['String'];
note?: Maybe<Scalars['String']>;
recommendedAt: Scalars['Date'];
user?: Maybe<RecommendingUser>;
};
export type RecommendationGroup = {
@ -1815,6 +1818,14 @@ export type RecommendationGroup = {
updatedAt: Scalars['Date'];
};
export type RecommendingUser = {
__typename?: 'RecommendingUser';
name: Scalars['String'];
profileImageURL?: Maybe<Scalars['String']>;
userId: Scalars['String'];
username: Scalars['String'];
};
export type Reminder = {
__typename?: 'Reminder';
archiveUntil: Scalars['Boolean'];
@ -2046,7 +2057,7 @@ export type SearchItem = {
readAt?: Maybe<Scalars['Date']>;
readingProgressAnchorIndex: Scalars['Int'];
readingProgressPercent: Scalars['Float'];
recommendedBy?: Maybe<Array<Recommendation>>;
recommendations?: Maybe<Array<Recommendation>>;
savedAt: Scalars['Date'];
shortId?: Maybe<Scalars['String']>;
siteIcon?: Maybe<Scalars['String']>;
@ -3230,6 +3241,7 @@ export type ResolversTypes = {
RecommendSuccess: ResolverTypeWrapper<RecommendSuccess>;
Recommendation: ResolverTypeWrapper<Recommendation>;
RecommendationGroup: ResolverTypeWrapper<RecommendationGroup>;
RecommendingUser: ResolverTypeWrapper<RecommendingUser>;
Reminder: ResolverTypeWrapper<Reminder>;
ReminderError: ResolverTypeWrapper<ReminderError>;
ReminderErrorCode: ReminderErrorCode;
@ -3631,6 +3643,7 @@ export type ResolversParentTypes = {
RecommendSuccess: RecommendSuccess;
Recommendation: Recommendation;
RecommendationGroup: RecommendationGroup;
RecommendingUser: RecommendingUser;
Reminder: Reminder;
ReminderError: ReminderError;
ReminderResult: ResolversParentTypes['ReminderError'] | ResolversParentTypes['ReminderSuccess'];
@ -3876,7 +3889,7 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
readAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
readingProgressAnchorIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
readingProgressPercent?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
recommendedBy?: Resolver<Maybe<Array<ResolversTypes['Recommendation']>>, ParentType, ContextType>;
recommendations?: Resolver<Maybe<Array<ResolversTypes['Recommendation']>>, ParentType, ContextType>;
savedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
savedByViewer?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
shareInfo?: Resolver<Maybe<ResolversTypes['LinkShareInfo']>, ParentType, ContextType>;
@ -4836,7 +4849,9 @@ export type RecommendSuccessResolvers<ContextType = ResolverContext, ParentType
export type RecommendationResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Recommendation'] = ResolversParentTypes['Recommendation']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
note?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
recommendedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes['RecommendingUser']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -4851,6 +4866,14 @@ export type RecommendationGroupResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RecommendingUserResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecommendingUser'] = ResolversParentTypes['RecommendingUser']> = {
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
profileImageURL?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
userId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
username?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ReminderResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Reminder'] = ResolversParentTypes['Reminder']> = {
archiveUntil?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@ -4993,7 +5016,7 @@ export type SearchItemResolvers<ContextType = ResolverContext, ParentType extend
readAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
readingProgressAnchorIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
readingProgressPercent?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
recommendedBy?: Resolver<Maybe<Array<ResolversTypes['Recommendation']>>, ParentType, ContextType>;
recommendations?: Resolver<Maybe<Array<ResolversTypes['Recommendation']>>, ParentType, ContextType>;
savedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
shortId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteIcon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -5718,6 +5741,7 @@ export type Resolvers<ContextType = ResolverContext> = {
RecommendSuccess?: RecommendSuccessResolvers<ContextType>;
Recommendation?: RecommendationResolvers<ContextType>;
RecommendationGroup?: RecommendationGroupResolvers<ContextType>;
RecommendingUser?: RecommendingUserResolvers<ContextType>;
Reminder?: ReminderResolvers<ContextType>;
ReminderError?: ReminderErrorResolvers<ContextType>;
ReminderResult?: ReminderResultResolvers<ContextType>;

View file

@ -86,7 +86,7 @@ type Article {
readAt: Date
readingProgressAnchorIndex: Int!
readingProgressPercent: Float!
recommendedBy: [Recommendation!]
recommendations: [Recommendation!]
savedAt: Date!
savedByViewer: Boolean
shareInfo: LinkShareInfo
@ -1250,6 +1250,7 @@ enum RecommendErrorCode {
input RecommendInput {
groupIds: [ID!]!
note: String
pageId: ID!
}
@ -1262,7 +1263,9 @@ type RecommendSuccess {
type Recommendation {
id: ID!
name: String!
note: String
recommendedAt: Date!
user: RecommendingUser
}
type RecommendationGroup {
@ -1275,6 +1278,13 @@ type RecommendationGroup {
updatedAt: Date!
}
type RecommendingUser {
name: String!
profileImageURL: String
userId: String!
username: String!
}
type Reminder {
archiveUntil: Boolean!
id: ID!
@ -1488,7 +1498,7 @@ type SearchItem {
readAt: Date
readingProgressAnchorIndex: Int!
readingProgressPercent: Float!
recommendedBy: [Recommendation!]
recommendations: [Recommendation!]
savedAt: Date!
shortId: String
siteIcon: String

View file

@ -42,7 +42,7 @@ export const createArticleSavingRequestResolver = authorized<
articleSavingRequest: request,
}
} catch (err) {
console.log('error', err)
console.log('error saving article', err)
if (isErrorWithCode(err)) {
return {
errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode],

View file

@ -201,7 +201,7 @@ export const deleteLabelResolver = authorized<
label,
}
} catch (error) {
log.error('error', error)
log.error('error deleting label', error)
return {
errorCodes: [DeleteLabelErrorCode.BadRequest],
}

View file

@ -146,8 +146,9 @@ export const recommendResolver = authorized<
})
try {
const user = await getRepository(User).findOneBy({
id: uid,
const user = await getRepository(User).findOne({
where: { id: uid },
relations: ['profile'],
})
if (!user) {
return {
@ -184,7 +185,15 @@ export const recommendResolver = authorized<
member.user.id,
page.id,
{
...group,
id: group.id,
name: group.name,
note: input.note ?? null,
user: {
userId: user.id,
name: user.name,
username: user.profile.username,
profileImageURL: user.profile.pictureUrl,
},
recommendedAt: new Date(),
},
auth
@ -198,6 +207,7 @@ export const recommendResolver = authorized<
taskNames,
}
} catch (error) {
console.log('Error recommending: ', error)
log.error('Error recommending', {
error,
labels: {

View file

@ -211,7 +211,7 @@ export const googleSignupResolver: ResolverFn<
me: userDataToUser({ ...user, profile: { ...profile, private: false } }),
}
} catch (err) {
console.log('error', err)
console.log('error signing up with google', err)
if (isErrorWithCode(err)) {
return { errorCodes: [err.errorCode as SignupErrorCode] }
}

View file

@ -72,7 +72,7 @@ export async function createMobileEmailSignUpResponse(
json: {},
}
} catch (e) {
console.log('error', e)
console.log('error creating mobile email sign up response', e)
return signUpFailedPayload
}
}

View file

@ -329,10 +329,19 @@ const schema = gql`
updatedAt: Date!
}
type RecommendingUser {
userId: String!
name: String!
username: String!
profileImageURL: String
}
type Recommendation {
id: ID!
name: String!
user: RecommendingUser
recommendedAt: Date!
note: String
}
type Article {
@ -375,7 +384,7 @@ const schema = gql`
state: ArticleSavingRequestStatus
language: String
readAt: Date
recommendedBy: [Recommendation!]
recommendations: [Recommendation!]
}
# Query: article
@ -1522,7 +1531,7 @@ const schema = gql`
savedAt: Date!
highlights: [Highlight!]
siteIcon: String
recommendedBy: [Recommendation!]
recommendations: [Recommendation!]
}
type SearchItemEdge {
@ -2194,6 +2203,7 @@ const schema = gql`
input RecommendInput {
pageId: ID!
groupIds: [ID!]!
note: String
}
union RecommendResult = RecommendSuccess | RecommendError

View file

@ -423,7 +423,7 @@ export const enqueueRecommendation = async (
}
const headers = {
Authorization: authToken,
Cookie: `auth=${authToken}`,
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {

View file

@ -7,13 +7,19 @@
"analyzer": {
"strip_html_analyzer": {
"tokenizer": "standard",
"char_filter": ["html_strip"],
"filter": ["lowercase"]
"char_filter": [
"html_strip"
],
"filter": [
"lowercase"
]
}
},
"normalizer": {
"lowercase_normalizer": {
"filter": ["lowercase"]
"filter": [
"lowercase"
]
}
}
}
@ -161,7 +167,7 @@
"wordsCount": {
"type": "integer"
},
"recommendedBy": {
"recommendations": {
"type": "nested",
"properties": {
"id": {
@ -173,9 +179,31 @@
},
"recommendedAt": {
"type": "date"
},
"note": {
"type": "text"
},
"user": {
"type": "nested",
"properties": {
"userID": {
"type": "keyword"
},
"name": {
"type": "keyword",
"normalizer": "lowercase_normalizer"
},
"username": {
"type": "keyword",
"normalizer": "lowercase_normalizer"
},
"profileImageURL": {
"type": "keyword"
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,51 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export interface RecommendationGroup {
id: string
name: string
createdAt: Date
updatedAt: Date
}
type JoinGroupResponse = {
joinGroup?: JoinGroupData
errorCodes?: string[]
}
type JoinGroupData = {
group: RecommendationGroup | undefined
}
export async function joinGroupMutation(
inviteCode: string
): Promise<RecommendationGroup | undefined> {
const mutation = gql`
mutation JoinGroup($inviteCode: String!) {
joinGroup(inviteCode: $inviteCode) {
... on JoinGroupSuccess {
group {
id
name
createdAt
updatedAt
}
}
... on JoinGroupError {
errorCodes
}
}
}
`
console.log('JoinGroupMutation', mutation)
const response = await gqlFetcher(mutation, { inviteCode })
console.log(' -- response', response)
const data = response as JoinGroupResponse | undefined
const error = data?.errorCodes?.find(() => true)
if (error) {
throw error
}
return data?.joinGroup?.group
}

View file

@ -80,8 +80,24 @@ export type LibraryItemNode = {
state: State
pageType: PageType
siteName?: string
subscription?: string,
subscription?: string
readAt?: string
recommendations?: Recommendation[]
}
export type Recommendation = {
id: string
name: string
note?: string
user?: RecommendingUser
recommendedAt: Date
}
export type RecommendingUser = {
userId: string
name: string
username: string
profileImageURL?: string
}
export type PageInfo = {
@ -135,6 +151,18 @@ export function useGetLibraryItemsQuery({
siteName
subscription
readAt
recommendations {
id
name
note
user {
userId
name
username
profileImageURL
}
recommendedAt
}
}
}
pageInfo {
@ -198,7 +226,7 @@ export function useGetLibraryItemsQuery({
}
const getIndexOf = (page: LibraryItems, item: LibraryItem) => {
return page.edges.findIndex(i => i.node.id === item.node.id)
return page.edges.findIndex((i) => i.node.id === item.node.id)
}
const performActionOnItem = async (
@ -329,7 +357,9 @@ export function useGetLibraryItemsQuery({
})
unsubscribeMutation(item.node.subscription).then((res) => {
if (res) {
showSuccessToast('Unsubscribed successfully', { position: 'bottom-right' })
showSuccessToast('Unsubscribed successfully', {
position: 'bottom-right',
})
} else {
showErrorToast('Error unsubscribing', {
position: 'bottom-right',
@ -337,8 +367,8 @@ export function useGetLibraryItemsQuery({
}
})
}
case 'update-item':
updateData(item)
case 'update-item':
updateData(item)
break
case 'refresh':
await mutate()
@ -353,6 +383,6 @@ export function useGetLibraryItemsQuery({
performActionOnItem,
size,
setSize,
mutate
mutate,
}
}

View file

@ -1,7 +1,6 @@
import { PrimaryLayout } from '../components/templates/PrimaryLayout'
import { HomeFeedContainer } from '../components/templates/homeFeed/HomeFeedContainer'
import { VStack } from './../components/elements/LayoutPrimitives'
import { useRef } from 'react'
export default function Home(): JSX.Element {
return <LoadedContent />

View file

@ -0,0 +1,45 @@
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { PageMetaData } from '../../components/patterns/PageMetaData'
import { ProfileLayout } from '../../components/templates/ProfileLayout'
import { joinGroupMutation } from '../../lib/networking/mutations/joinGroupMutation'
import { showSuccessToast } from '../../lib/toastHelpers'
export default function InvitePage(): JSX.Element {
const router = useRouter()
const { inviteCode } = router.query
const [error, setError] = useState<string | undefined>(undefined)
useEffect(() => {
if (!router.isReady) {
return
}
if (!inviteCode || typeof inviteCode != 'string') {
setError('No invite code provided')
}
const joinGroup = async () => {
try {
const result = await joinGroupMutation(inviteCode as string)
if (!result) {
throw 'Unknown error occurred.'
}
showSuccessToast(`You have been added to the ${result.name} group`)
} catch (error) {
setError('Unable to join group')
}
}
joinGroup().catch((error) => {
setError(error)
})
}, [router, inviteCode])
return (
<>
<PageMetaData title="Accept Invite - Omnivore" path="/invite" />
<ProfileLayout>Accepting invite to join</ProfileLayout>
<div data-testid="invite-page-tag" />
</>
)
}