mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1510 from omnivore-app/feat/ios-recommendations
iOS recommendation groups
This commit is contained in:
commit
7b2549c6e7
51 changed files with 4044 additions and 157 deletions
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ struct ProfileView: View {
|
|||
NavigationLink(destination: SubscriptionsView()) {
|
||||
Text("Subscriptions")
|
||||
}
|
||||
|
||||
NavigationLink(destination: GroupsView()) {
|
||||
Text("Recommendation Groups")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "" }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? "" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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: []
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/web/lib/networking/mutations/joinGroupMutation.ts
Normal file
51
packages/web/lib/networking/mutations/joinGroupMutation.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
45
packages/web/pages/invite/[inviteCode].tsx
Normal file
45
packages/web/pages/invite/[inviteCode].tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue