mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Clean up iOS recommendations, show a primer on first load
This commit is contained in:
parent
965d7286a5
commit
55e925cc98
21 changed files with 467 additions and 139 deletions
|
|
@ -5,7 +5,7 @@ import Views
|
|||
|
||||
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var networkError = true
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroup: InternalRecommendationGroup
|
||||
|
||||
init(recommendationGroup: InternalRecommendationGroup) {
|
||||
|
|
@ -71,8 +71,6 @@ struct RecommendationGroupView: View {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel: RecommendationsGroupViewModel
|
||||
|
||||
@State var presentShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
|
|
@ -110,12 +108,22 @@ struct RecommendationGroupView: View {
|
|||
|
||||
private var membersSection: some View {
|
||||
Section("Members") {
|
||||
ForEach(viewModel.nonAdmins) { member in
|
||||
SmallUserCard(data: ProfileCardData(
|
||||
name: member.name,
|
||||
username: member.username,
|
||||
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
|
||||
))
|
||||
if viewModel.nonAdmins.count > 0 {
|
||||
ForEach(viewModel.nonAdmins) { member in
|
||||
SmallUserCard(data: ProfileCardData(
|
||||
name: member.name,
|
||||
username: member.username,
|
||||
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Text("""
|
||||
This group does not have any members. Add users to your group by sending
|
||||
them the invite link.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +136,17 @@ struct RecommendationGroupView: View {
|
|||
|
||||
Section("Invite Link") {
|
||||
Button(action: {
|
||||
presentShareSheet = true
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([highlightParams.quote as NSString])
|
||||
#endif
|
||||
|
||||
Snackbar.show(message: "Invite link copied")
|
||||
}, label: {
|
||||
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
|
||||
.font(.appCaption)
|
||||
|
|
@ -138,9 +156,6 @@ struct RecommendationGroupView: View {
|
|||
adminsSection
|
||||
membersSection
|
||||
}
|
||||
.formSheet(isPresented: $presentShareSheet) {
|
||||
shareView
|
||||
}
|
||||
.navigationTitle(viewModel.recommendationGroup.name)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Views
|
|||
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isCreating = false
|
||||
@Published var networkError = true
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
|
||||
@Published var showCreateSheet = false
|
||||
|
|
@ -130,14 +130,29 @@ struct GroupsView: View {
|
|||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
|
||||
Section(header: Text("Your recommendation groups")) {
|
||||
ForEach(viewModel.recommendationGroups) { recommendationGroup in
|
||||
NavigationLink(
|
||||
destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup))
|
||||
) {
|
||||
Text(recommendationGroup.name)
|
||||
if viewModel.recommendationGroups.count > 0 {
|
||||
Section(header: Text("Your recommendation groups")) {
|
||||
ForEach(viewModel.recommendationGroups) { recommendationGroup in
|
||||
NavigationLink(
|
||||
destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup))
|
||||
) {
|
||||
Text(recommendationGroup.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text("""
|
||||
You are not a member of any groups.
|
||||
Create a new group and send the invite link to your friends get started.
|
||||
|
||||
During the beta you are limited to creating three groups, and each group
|
||||
can have a maximum of twelve users.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Recommendation Groups")
|
||||
|
|
|
|||
|
|
@ -47,30 +47,35 @@ struct InnerRootView: View {
|
|||
|
||||
@ViewBuilder private var innerBody: some View {
|
||||
if authenticator.isLoggedIn {
|
||||
PrimaryContentView()
|
||||
.onAppear {
|
||||
viewModel.triggerPushNotificationRequestIfNeeded()
|
||||
}
|
||||
#if os(iOS)
|
||||
.miniPlayer()
|
||||
#endif
|
||||
.snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage)
|
||||
// Schedule the dismissal every time we present the snackbar.
|
||||
.onChange(of: viewModel.showSnackbar) { newValue in
|
||||
if newValue {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
viewModel.showSnackbar = false
|
||||
GeometryReader { geo in
|
||||
PrimaryContentView()
|
||||
.onAppear {
|
||||
viewModel.triggerPushNotificationRequestIfNeeded()
|
||||
}
|
||||
#if os(iOS)
|
||||
.miniPlayer()
|
||||
.formSheet(isPresented: $viewModel.showNewFeaturePrimer, modalSize: CGSize(width: geo.size.width * 0.66, height: geo.size.width * 0.66)) {
|
||||
FeaturePrimer.recommendationsPrimer
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
|
||||
viewModel.showNewFeaturePrimer = viewModel.shouldShowNewFeaturePrimer
|
||||
viewModel.shouldShowNewFeaturePrimer = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage)
|
||||
// Schedule the dismissal every time we present the snackbar.
|
||||
.onChange(of: viewModel.showSnackbar) { newValue in
|
||||
if newValue {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
viewModel.showSnackbar = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.customAlert(isPresented: $viewModel.showPushNotificationPrimer) {
|
||||
pushNotificationPrimerView
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
} else {
|
||||
WelcomeView()
|
||||
.accessibilityElement()
|
||||
|
|
@ -105,14 +110,14 @@ struct InnerRootView: View {
|
|||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var pushNotificationPrimerView: PushNotificationPrimer {
|
||||
PushNotificationPrimer(
|
||||
acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() },
|
||||
denyAction: {
|
||||
UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue)
|
||||
viewModel.showPushNotificationPrimer = false
|
||||
}
|
||||
)
|
||||
}
|
||||
// private var pushNotificationPrimerView: PushNotificationPrimer {
|
||||
// PushNotificationPrimer(
|
||||
// acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() },
|
||||
// denyAction: {
|
||||
// UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue)
|
||||
// viewModel.showPushNotificationPrimer = false
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import Views
|
|||
public final class RootViewModel: ObservableObject {
|
||||
let services = Services()
|
||||
|
||||
@Published public var showPushNotificationPrimer = false
|
||||
@Published public var showNewFeaturePrimer = false
|
||||
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = true
|
||||
|
||||
@Published var snackbarMessage: String?
|
||||
@Published var showSnackbar = false
|
||||
@Published var showMiniPlayer = true
|
||||
|
|
@ -34,32 +36,32 @@ public final class RootViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
func triggerPushNotificationRequestIfNeeded() {
|
||||
guard FeatureFlag.enablePushNotifications else { return }
|
||||
|
||||
if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .notDetermined:
|
||||
DispatchQueue.main.async {
|
||||
self?.showPushNotificationPrimer = true
|
||||
}
|
||||
case .authorized, .provisional, .ephemeral, .denied:
|
||||
return
|
||||
@unknown default:
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// guard FeatureFlag.enablePushNotifications else { return }
|
||||
//
|
||||
// if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// #if os(iOS)
|
||||
// UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
||||
// switch settings.authorizationStatus {
|
||||
// case .notDetermined:
|
||||
// DispatchQueue.main.async {
|
||||
// self?.showPushNotificationPrimer = true
|
||||
// }
|
||||
// case .authorized, .provisional, .ephemeral, .denied:
|
||||
// return
|
||||
// @unknown default:
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// #endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func handlePushNotificationPrimerAcceptance() {
|
||||
showPushNotificationPrimer = false
|
||||
UNUserNotificationCenter.current().requestAuth()
|
||||
// showPushNotificationPrimer = false
|
||||
// UNUserNotificationCenter.current().requestAuth()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import Views
|
|||
|
||||
@MainActor final class RecommendToViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var networkError = true
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
@Published var selectedGroups = [String]()
|
||||
@Published var selectedGroups = [InternalRecommendationGroup]()
|
||||
@Published var isRunning = false
|
||||
@Published var showError = false
|
||||
@Published var note: String?
|
||||
@Published var showNoteView = false
|
||||
@Published var note: String = ""
|
||||
|
||||
let pageID: String
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ import Views
|
|||
isRunning = true
|
||||
|
||||
do {
|
||||
try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups, note: note)
|
||||
try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups.map(\.id), note: note.isEmpty ? nil : note)
|
||||
} catch {
|
||||
showError = true
|
||||
}
|
||||
|
|
@ -50,6 +51,16 @@ struct RecommendToView: View {
|
|||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var nextButton: some View {
|
||||
Button(action: {
|
||||
self.viewModel.showNoteView = true
|
||||
}, label: {
|
||||
Text("Next")
|
||||
.bold()
|
||||
})
|
||||
.disabled(viewModel.selectedGroups.isEmpty)
|
||||
}
|
||||
|
||||
var sendButton: some View {
|
||||
if viewModel.isRunning {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
|
|
@ -60,15 +71,60 @@ struct RecommendToView: View {
|
|||
dismiss()
|
||||
}
|
||||
}, label: {
|
||||
Text("Next")
|
||||
Text("Send")
|
||||
.bold()
|
||||
})
|
||||
.disabled(viewModel.selectedGroups.isEmpty)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var noteView: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("To:")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
Text(InternalRecommendationGroup.readable(list: viewModel.selectedGroups))
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
Spacer()
|
||||
}
|
||||
TextEditor(text: $viewModel.note)
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(.appBody)
|
||||
.padding(12)
|
||||
.frame(height: 200)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
Text("Add a note (optional)")
|
||||
.allowsHitTesting(false)
|
||||
.opacity(viewModel.note.isEmpty ? 0.4 : 0.0)
|
||||
.font(.appBody)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.top, 24)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarItems(trailing: sendButton)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
NavigationLink(destination: noteView,
|
||||
isActive: $viewModel.showNoteView) {
|
||||
EmptyView()
|
||||
}
|
||||
List {
|
||||
Section("Select groups to recommend to") {
|
||||
ForEach(viewModel.recommendationGroups) { group in
|
||||
|
|
@ -77,17 +133,17 @@ struct RecommendToView: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
if viewModel.selectedGroups.contains(group.id) {
|
||||
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let idx = viewModel.selectedGroups.firstIndex(of: group.id)
|
||||
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
|
||||
if let idx = idx {
|
||||
viewModel.selectedGroups.remove(at: idx)
|
||||
} else {
|
||||
viewModel.selectedGroups.append(group.id)
|
||||
viewModel.selectedGroups.append(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +161,6 @@ struct RecommendToView: View {
|
|||
}
|
||||
)
|
||||
}
|
||||
.navigationBarTitle("Recommend")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarItems(leading: Button(action: {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ struct WebReaderContent {
|
|||
readingProgressAnchorIndex: \(item.readingProgressAnchor),
|
||||
labels: \(item.labelsJSONString),
|
||||
highlights: \(articleContent.highlightsJSONString),
|
||||
recommendations: \(item.recommendationsJSONString),
|
||||
}
|
||||
|
||||
window.fontSize = \(textFontSize)
|
||||
|
|
|
|||
|
|
@ -55,7 +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"/>
|
||||
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkeditem" inverseEntity="Recommendation"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
|
|
@ -95,8 +95,9 @@
|
|||
<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="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="recommendedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendations" inverseEntity="LinkedItem"/>
|
||||
<relationship name="linkeditem" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendations" inverseEntity="LinkedItem"/>
|
||||
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
|
||||
</entity>
|
||||
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">
|
||||
|
|
|
|||
|
|
@ -129,6 +129,26 @@ public extension LinkedItem {
|
|||
return String(data: JSON, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
var recommendationsJSONString: String {
|
||||
let recommendations = self.recommendations.asArray(of: Recommendation.self).map { recommendation in
|
||||
let recommendedAt = recommendation.recommendedAt == nil ? nil : recommendation.recommendedAt?.ISO8601Format()
|
||||
return [
|
||||
"id": NSString(string: recommendation.id ?? ""),
|
||||
"name": NSString(string: recommendation.name ?? ""),
|
||||
"note": recommendation.note == nil ? nil : NSString(string: recommendation.note ?? ""),
|
||||
"user": recommendation.user == nil ? nil : NSDictionary(dictionary: [
|
||||
"userID": NSString(string: recommendation.user?.userID ?? ""),
|
||||
"name": NSString(string: recommendation.user?.name ?? ""),
|
||||
"username": NSString(string: recommendation.user?.username ?? ""),
|
||||
"profileImageURL": recommendation.user?.profileImageURL == nil ? nil : NSString(string: recommendation.user?.profileImageURL ?? "")
|
||||
]),
|
||||
"recommendedAt": recommendedAt == nil ? nil : NSString(string: recommendedAt!)
|
||||
]
|
||||
}
|
||||
guard let JSON = (try? JSONSerialization.data(withJSONObject: recommendations, options: .prettyPrinted)) else { return "[]" }
|
||||
return String(data: JSON, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
var formattedByline: String {
|
||||
var byline = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -18,4 +18,30 @@ public extension Recommendation {
|
|||
|
||||
return recommendation
|
||||
}
|
||||
|
||||
static func byline(_ set: NSSet) -> String {
|
||||
Array(set).reduce("") { str, item in
|
||||
if let recommendation = item as? Recommendation, let userName = recommendation.user?.name {
|
||||
if str.isEmpty {
|
||||
return userName
|
||||
} else {
|
||||
return str + ", " + userName
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
static func groupsLine(_ set: NSSet) -> String {
|
||||
Array(set).reduce("") { str, item in
|
||||
if let recommendation = item as? Recommendation, let name = recommendation.name {
|
||||
if str.isEmpty {
|
||||
return name
|
||||
} else {
|
||||
return str + ", " + name
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,37 +225,22 @@ extension DataService {
|
|||
}
|
||||
|
||||
let recommendingUserSelection = Selection.RecommendingUser {
|
||||
do {
|
||||
return InternalUserProfile(
|
||||
userID: try $0.userId(),
|
||||
name: try $0.name(),
|
||||
username: try $0.username(),
|
||||
profileImageURL: nil // try $0.profileImageUrl() ?? nil
|
||||
)
|
||||
} catch {
|
||||
print("ERROR WITH recommendingUserSelection", error)
|
||||
throw error
|
||||
}
|
||||
InternalUserProfile(
|
||||
userID: try $0.userId(),
|
||||
name: try $0.name(),
|
||||
username: try $0.username(),
|
||||
profileImageURL: nil // try $0.profileImageUrl() ?? nil
|
||||
)
|
||||
}
|
||||
|
||||
let recommendationSelection = Selection.Recommendation {
|
||||
do {
|
||||
let result = InternalRecommendation(
|
||||
id: try $0.id(),
|
||||
name: try $0.name(),
|
||||
user: try $0.user(selection: recommendingUserSelection.nullable),
|
||||
recommendedAt: try $0.recommendedAt().value ?? Date()
|
||||
)
|
||||
return result
|
||||
} catch {
|
||||
print("ERROR WITH recommendationSelection", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyrecommended() -> [InternalRecommendation] {
|
||||
print("got the empty InternalRecommendation")
|
||||
return []
|
||||
InternalRecommendation(
|
||||
id: try $0.id(),
|
||||
name: try $0.name(),
|
||||
note: try $0.note(),
|
||||
user: try $0.user(selection: recommendingUserSelection.nullable),
|
||||
recommendedAt: try $0.recommendedAt().value ?? Date()
|
||||
)
|
||||
}
|
||||
|
||||
private let libraryArticleSelection = Selection.Article {
|
||||
|
|
@ -283,7 +268,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? emptyrecommended(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Models
|
|||
public struct InternalRecommendation {
|
||||
let id: String
|
||||
let name: String
|
||||
let note: String?
|
||||
let user: InternalUserProfile?
|
||||
let recommendedAt: Date
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ public struct InternalRecommendation {
|
|||
let recommendation = existing ?? Recommendation(entity: Recommendation.entity(), insertInto: context)
|
||||
recommendation.id = id
|
||||
recommendation.name = name
|
||||
recommendation.note = note
|
||||
recommendation.recommendedAt = recommendedAt
|
||||
recommendation.user = user?.asManagedObject(inContext: context)
|
||||
return recommendation
|
||||
|
|
@ -29,6 +31,7 @@ public struct InternalRecommendation {
|
|||
return InternalRecommendation(
|
||||
id: id,
|
||||
name: name,
|
||||
note: recommendation.note,
|
||||
user: InternalUserProfile.makeSingle(recommendation.user),
|
||||
recommendedAt: recommendedAt
|
||||
)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,16 @@ public struct InternalRecommendationGroup: Identifiable {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func readable(list: [InternalRecommendationGroup]) -> String {
|
||||
list.reduce("") { str, group in
|
||||
if str.isEmpty {
|
||||
return group.name
|
||||
} else {
|
||||
return str + ", " + group.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == InternalRecommendationGroup {
|
||||
|
|
|
|||
|
|
@ -23,4 +23,5 @@ public enum UserDefaultKey: String {
|
|||
case recentSearchTerms
|
||||
case audioPlayerExpanded
|
||||
case themeName
|
||||
case shouldShowNewFeaturePrimer
|
||||
}
|
||||
|
|
|
|||
69
apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift
Normal file
69
apple/OmnivoreKit/Sources/Views/FeaturePrimer.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 12/7/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct FeaturePrimer: View {
|
||||
let isBeta: Bool
|
||||
let title: String
|
||||
let message: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.textToSpeechRead)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if isBeta {
|
||||
HStack {
|
||||
TextChip(text: "¡ Beta !", color: Color.red)
|
||||
.frame(alignment: .leading)
|
||||
TextChip(text: "¡ New Feature !", color: Color.green)
|
||||
.frame(alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Text((try? AttributedString(markdown: message,
|
||||
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? "")
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.accentColor(.blue)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text("Dismiss") })
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}.padding()
|
||||
}
|
||||
|
||||
public static var recommendationsPrimer: some View {
|
||||
FeaturePrimer(
|
||||
isBeta: true,
|
||||
title: "Introducing Recommendation Groups",
|
||||
message: """
|
||||
Recommendation groups make it easy to share great reads with friends and co-workers.
|
||||
|
||||
To get started, create a Recommendation Group from the profile page and invite some friends.
|
||||
|
||||
*During the beta you can create a max of three groups. Group sizes are limited to 12 people.*
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,19 +88,10 @@ public struct FeedCard: View {
|
|||
}
|
||||
|
||||
if let recommendations = item.recommendations, recommendations.count > 0 {
|
||||
let byStr = recommendations.reduce("") { str, item in
|
||||
if let item = item as? Recommendation, let name = item.user?.name {
|
||||
return str + name
|
||||
}
|
||||
return str
|
||||
}
|
||||
let inStr = recommendations.reduce("") { str, item in
|
||||
if let item = item as? Recommendation, let name = item.name {
|
||||
return str + name
|
||||
}
|
||||
return str
|
||||
}
|
||||
let byStr = Recommendation.byline(recommendations)
|
||||
let inStr = Recommendation.groupsLine(recommendations)
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
Text("Recommended by \(byStr) in \(inStr)")
|
||||
.font(.appCaption)
|
||||
.frame(alignment: .leading)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import SwiftUI
|
|||
controller.view.sizeToFit()
|
||||
controller.modalPresentationStyle = .formSheet
|
||||
controller.modalTransitionStyle = .crossDissolve
|
||||
controller.preferredContentSize = CGSize(width: 320, height: 320)
|
||||
controller.preferredContentSize = modalSize
|
||||
}
|
||||
|
||||
controller.presentationController?.delegate = self
|
||||
|
|
|
|||
|
|
@ -22,6 +22,28 @@ const textVariants = {
|
|||
fontSize: '$2',
|
||||
lineHeight: '1.25',
|
||||
},
|
||||
recommendedByline: {
|
||||
// fontWeight: 'bold',
|
||||
fontSize: '13.5px',
|
||||
paddingTop: '4px',
|
||||
mt: '0px',
|
||||
mb: '24px',
|
||||
color: '$grayText',
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
fontSize: '13.5px',
|
||||
paddingTop: '4px',
|
||||
my: '6px',
|
||||
color: '$grayText',
|
||||
},
|
||||
userNote: {
|
||||
fontSize: '16px',
|
||||
paddingTop: '0px',
|
||||
marginTop: '0px',
|
||||
lineHeight: '1.5',
|
||||
color: '$grayTextContrast',
|
||||
},
|
||||
headline: {
|
||||
fontSize: '$4',
|
||||
'@md': {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { Article } from './../../../components/templates/article/Article'
|
||||
import { Box, SpanBox, VStack } from './../../elements/LayoutPrimitives'
|
||||
import {
|
||||
Blockquote,
|
||||
Box,
|
||||
HStack,
|
||||
SpanBox,
|
||||
VStack,
|
||||
} from './../../elements/LayoutPrimitives'
|
||||
import { StyledText } from './../../elements/StyledText'
|
||||
import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
|
||||
import { theme, ThemeId } from './../../tokens/stitches.config'
|
||||
import { styled, theme, ThemeId } from './../../tokens/stitches.config'
|
||||
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { MutableRefObject, useEffect, useState, useRef } from 'react'
|
||||
import {
|
||||
MutableRefObject,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useReducer,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { ReportIssuesModal } from './ReportIssuesModal'
|
||||
import { reportIssueMutation } from '../../../lib/networking/mutations/reportIssueMutation'
|
||||
import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
|
||||
|
|
@ -19,6 +32,9 @@ import {
|
|||
HighlightLocation,
|
||||
makeHighlightStartEndOffset,
|
||||
} from '../../../lib/highlights/highlightGenerator'
|
||||
import { Recommendation } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
|
||||
import { Avatar } from '../../elements/Avatar'
|
||||
import { Sparkle } from 'phosphor-react'
|
||||
|
||||
type ArticleContainerProps = {
|
||||
article: ArticleAttributes
|
||||
|
|
@ -41,12 +57,15 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
|
||||
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
|
||||
// iOS app embed can overide the original margin and line height
|
||||
const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] =
|
||||
useState<number | null>(null)
|
||||
const [lineHeightOverride, setLineHeightOverride] =
|
||||
useState<number | null>(null)
|
||||
const [fontFamilyOverride, setFontFamilyOverride] =
|
||||
useState<string | null>(null)
|
||||
const [maxWidthPercentageOverride, setMaxWidthPercentageOverride] = useState<
|
||||
number | null
|
||||
>(null)
|
||||
const [lineHeightOverride, setLineHeightOverride] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [fontFamilyOverride, setFontFamilyOverride] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [highContrastFont, setHighContrastFont] = useState(
|
||||
props.highContrastFont ?? false
|
||||
)
|
||||
|
|
@ -196,6 +215,31 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
readerHeadersColor: theme.colors.readerHeader.toString(),
|
||||
}
|
||||
|
||||
const recommendationByline = useMemo(() => {
|
||||
return props.article.recommendations
|
||||
?.flatMap((recommendation) => {
|
||||
return recommendation.user?.name
|
||||
})
|
||||
.join(', ')
|
||||
}, [props.article.recommendations])
|
||||
|
||||
const recommendationsWithNotes = useMemo(() => {
|
||||
return (
|
||||
props.article.recommendations?.filter((recommendation) => {
|
||||
return recommendation.note
|
||||
}) ?? []
|
||||
)
|
||||
}, [props.article.recommendations])
|
||||
|
||||
const StyledQuote = styled(Blockquote, {
|
||||
margin: '0px 0px 0px 0px',
|
||||
fontSize: '18px',
|
||||
lineHeight: '27px',
|
||||
color: '$grayText',
|
||||
padding: '0px 16px',
|
||||
borderLeft: '2px solid $omnivoreCtaYellow',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
|
|
@ -266,6 +310,45 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
))}
|
||||
</SpanBox>
|
||||
) : null}
|
||||
{recommendationByline && (
|
||||
<VStack
|
||||
css={{
|
||||
borderRadius: '6px',
|
||||
bg: '$grayBase',
|
||||
p: '16px',
|
||||
pt: '16px',
|
||||
width: '100%',
|
||||
marginTop: '24px',
|
||||
color: '$grayText',
|
||||
lineHeight: '2.0',
|
||||
}}
|
||||
>
|
||||
<HStack css={{ gap: '8px' }}>
|
||||
<Sparkle size="14" />
|
||||
<StyledText
|
||||
style="recommendedByline"
|
||||
css={{ paddingTop: '0px' }}
|
||||
>
|
||||
Recommended by {recommendationByline}
|
||||
</StyledText>
|
||||
</HStack>
|
||||
|
||||
{recommendationsWithNotes.map((item, idx) => (
|
||||
<VStack key={item.id} alignment="start" distribution="start">
|
||||
{/* <StyledQuote>{item.note}</StyledQuote> */}
|
||||
<HStack css={{}} alignment="start">
|
||||
<StyledText style="userNote">
|
||||
<SpanBox css={{ opacity: '0.5' }}>
|
||||
{item.user?.name}:
|
||||
</SpanBox>{' '}
|
||||
{item.note}
|
||||
</StyledText>
|
||||
:
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
<Article
|
||||
articleId={props.article.id}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export type LabelColor =
|
|||
| '#7BE4FF'
|
||||
| '#CE88EF'
|
||||
| '#EF8C43'
|
||||
| 'custom color';
|
||||
| 'custom color'
|
||||
|
||||
export const labelFragment = gql`
|
||||
fragment LabelFields on Label {
|
||||
|
|
@ -25,4 +25,4 @@ export type Label = {
|
|||
color: LabelColor
|
||||
description?: string
|
||||
createdAt: Date
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import {
|
|||
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
|
||||
import { ScopedMutator } from 'swr/dist/types'
|
||||
import { Label, labelFragment } from '../fragments/labelFragment'
|
||||
import { LibraryItems } from './useGetLibraryItemsQuery'
|
||||
import {
|
||||
LibraryItems,
|
||||
Recommendation,
|
||||
recommendationFragment,
|
||||
} from './useGetLibraryItemsQuery'
|
||||
|
||||
type ArticleQueryInput = {
|
||||
username?: string
|
||||
|
|
@ -54,6 +58,7 @@ export type ArticleAttributes = {
|
|||
linkId: string
|
||||
labels?: Label[]
|
||||
state?: State
|
||||
recommendations?: Recommendation[]
|
||||
}
|
||||
|
||||
const query = gql`
|
||||
|
|
@ -73,6 +78,9 @@ const query = gql`
|
|||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
recommendations {
|
||||
...RecommendationFields
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ArticleError {
|
||||
|
|
@ -83,6 +91,7 @@ const query = gql`
|
|||
${articleFragment}
|
||||
${highlightFragment}
|
||||
${labelFragment}
|
||||
${recommendationFragment}
|
||||
`
|
||||
|
||||
export function useGetArticleQuery({
|
||||
|
|
@ -115,7 +124,7 @@ export function useGetArticleQuery({
|
|||
return {
|
||||
articleData: resultData,
|
||||
isLoading: !error && !data,
|
||||
articleFetchError: resultError ? resultError as string[] : null,
|
||||
articleFetchError: resultError ? (resultError as string[]) : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,21 @@ export type PageInfo = {
|
|||
totalCount: number
|
||||
}
|
||||
|
||||
export const recommendationFragment = gql`
|
||||
fragment RecommendationFields on Recommendation {
|
||||
id
|
||||
name
|
||||
note
|
||||
user {
|
||||
userId
|
||||
name
|
||||
username
|
||||
profileImageURL
|
||||
}
|
||||
recommendedAt
|
||||
}
|
||||
`
|
||||
|
||||
export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
|
|
|
|||
Loading…
Reference in a new issue