Merge pull request #1583 from omnivore-app/feat/ios-community

iOS clubs
This commit is contained in:
Jackson Harper 2022-12-30 17:37:28 +08:00 committed by GitHub
commit 8fc5e608c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 415 additions and 179 deletions

View file

@ -26,7 +26,7 @@ struct MacFeedCardNavigationLink: View {
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
FeedCard(item: item, viewer: dataService.currentViewer) {
viewModel.selectedLinkItem = item.objectID
@ -56,7 +56,7 @@ struct FeedCardNavigationLink: View {
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
FeedCard(item: item, viewer: dataService.currentViewer)
}
@ -95,7 +95,7 @@ struct GridCardNavigationLink: View {
withAnimation { tapAction() }
})
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
}
.aspectRatio(1.8, contentMode: .fill)

View file

@ -17,10 +17,11 @@ import Views
@EnvironmentObject var audioController: AudioController
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
@AppStorage(UserDefaultKey.shouldPromptCommunityModal.rawValue) var shouldPromptCommunityModal = true
@ObservedObject var viewModel: HomeFeedViewModel
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, audioController: audioController, isRefresh: isRefresh) }
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
}
var body: some View {
@ -38,6 +39,9 @@ import Views
prefersListLayout: $prefersListLayout,
viewModel: viewModel
)
.onAppear {
loadItems(isRefresh: true)
}
.refreshable {
loadItems(isRefresh: true)
}
@ -67,14 +71,31 @@ import Views
.sheet(item: $viewModel.itemForHighlightsView) { item in
HighlightsListView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
}
.sheet(isPresented: $viewModel.showCommunityModal) {
CommunityModal()
.onAppear {
shouldPromptCommunityModal = false
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .barLeading) {
Image.smallOmnivoreLogo
.renderingMode(.template)
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.appGrayTextContrast)
Button(action: {
viewModel.showCommunityModal = true
}, label: {
Image.smallOmnivoreLogo
.renderingMode(.template)
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.appGrayTextContrast)
.overlay(alignment: .topTrailing, content: {
if shouldPromptCommunityModal {
Circle()
.fill(Color.red)
.frame(width: 6, height: 6)
}
})
})
}
ToolbarItem(placement: .barTrailing) {
Button("", action: {})
@ -297,15 +318,10 @@ import Views
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
})
Button(
action: {
itemToRemove = item
confirmationShown = true
},
label: {
Label("Remove Item", systemImage: "trash")
}
).tint(.red)
Button("Remove Item", role: .destructive) {
itemToRemove = item
confirmationShown = true
}
if FeatureFlag.enableSnooze {
Button {
viewModel.itemToSnoozeID = item.id
@ -398,7 +414,7 @@ import Views
.listStyle(PlainListStyle())
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.",
isPresented: $confirmationShown) {
Button("Delete Item") {
Button("Remove Item", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
@ -440,7 +456,7 @@ import Views
}
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, audioController: audioController, isRefresh: isRefresh) }
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
}
var body: some View {

View file

@ -33,6 +33,16 @@ import Views
@Published var linkIsActive = false
@Published var showLabelsSheet = false
@Published var showCommunityModal = false
var cursor: String?
// These are used to make sure we handle search result
// responses in the right order
var searchIdx = 0
var receivedIdx = 0
var syncCursor: String?
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
@ -65,21 +75,16 @@ import Views
}
}
var cursor: String?
// These are used to make sure we handle search result
// responses in the right order
var searchIdx = 0
var receivedIdx = 0
func itemAppeared(item: LinkedItem, dataService: DataService, audioController: AudioController) async {
func itemAppeared(item: LinkedItem, dataService: DataService) async {
if isLoading { return }
let itemIndex = items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
// Check if user has scrolled to the last five items in the list
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
await loadItems(dataService: dataService, audioController: audioController, isRefresh: false)
// Make sure we aren't currently loading though, as this would get triggered when the first set
// of items are presented to the user.
if let itemIndex = itemIndex, itemIndex > thresholdIndex {
await loadMoreItems(dataService: dataService, isRefresh: false)
}
}
@ -103,14 +108,23 @@ import Views
}
}
func syncItems(dataService: DataService, syncStartTime: Date) async {
func syncItems(dataService: DataService) async {
let syncStart = Date.now
let lastSyncDate = dateFormatter.date(from: dataService.lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0)
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
cursor: nil,
deferFetchingMore: true)
if syncResult != nil {
dataService.lastItemSyncTime = dateFormatter.string(from: syncStartTime)
try? await dataService.syncOfflineItemsWithServerIfNeeded()
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
cursor: nil)
syncCursor = syncResult?.cursor
if let syncResult = syncResult, syncResult.hasMore {
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
// Set isLoading to false here
self.isLoading = false
}
} else {
dataService.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: syncStart)
}
// If possible start prefetching new pages in the background
@ -169,25 +183,20 @@ import Views
}
}
func loadItems(dataService: DataService, audioController _: AudioController, isRefresh: Bool) async {
let syncStartTime = Date()
func loadItems(dataService: DataService, isRefresh: Bool) async {
isLoading = true
showLoadingBar = true
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
group.addTask { await self.loadLabels(dataService: dataService) }
group.addTask { await self.syncItems(dataService: dataService, syncStartTime: syncStartTime) }
group.addTask { await self.syncItems(dataService: dataService) }
await group.waitForAll()
}
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty {
updateFetchController(dataService: dataService)
// For now we are forcing the search because we are fetching items in reverse
// with the sync API, but search fetches in descending order
if appliedFilter != LinkedItemFilter.inbox.rawValue {
if appliedFilter != LinkedItemFilter.inbox.rawValue || !isRefresh {
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
}
} else {
@ -198,6 +207,16 @@ import Views
showLoadingBar = false
}
func loadMoreItems(dataService: DataService, isRefresh: Bool) async {
isLoading = true
showLoadingBar = true
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
isLoading = false
showLoadingBar = false
}
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()

View file

@ -12,6 +12,10 @@ import Views
@Published var showCreateLabelModal = false
@Published var labelSearchFilter = ""
func setLabels(_ labels: [LinkedItemLabel]) {
self.labels = labels.sorted { $0.unwrappedName.trimmingCharacters(in: .whitespaces) < $1.unwrappedName.trimmingCharacters(in: .whitespaces) }
}
func loadLabels(
dataService: DataService,
item: LinkedItem? = nil,
@ -19,16 +23,22 @@ import Views
) async {
isLoading = true
if let labelIDs = try? await dataService.labels() {
dataService.viewContext.performAndWait {
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
}
let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? []
for label in labels {
if selLabels.contains(label) {
selectedLabels.append(label)
} else {
unselectedLabels.append(label)
await loadLabelsFromStore(dataService: dataService)
Task.detached(priority: .userInitiated) {
if let labelIDs = try? await dataService.labels() {
DispatchQueue.main.async {
dataService.viewContext.performAndWait {
self.setLabels(labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel })
}
let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? []
for label in self.labels {
if selLabels.contains(label) {
self.selectedLabels.append(label)
} else {
self.unselectedLabels.append(label)
}
}
}
}
}
@ -44,7 +54,7 @@ import Views
if let labelIDs = try? await dataService.labels() {
dataService.viewContext.performAndWait {
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
setLabels(labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel })
}
let selLabels = highlight.labels ?? []
for label in labels {
@ -69,7 +79,7 @@ import Views
if fetchedLabels?.count == 0 {
await fetchLabelsFromNetwork(dataService: dataService)
} else {
labels = fetchedLabels ?? []
setLabels(fetchedLabels ?? [])
unselectedLabels = fetchedLabels ?? []
}
}
@ -82,7 +92,7 @@ import Views
labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
}
labels = fetchedLabels
setLabels(fetchedLabels)
unselectedLabels = fetchedLabels
}

View file

@ -91,7 +91,7 @@ struct ProfileView: View {
}
NavigationLink(destination: GroupsView()) {
Text("Recommendation Groups")
Text("Clubs")
}
}
}

View file

@ -28,7 +28,7 @@ import Views
do {
try await dataService.leaveGroup(groupID: recommendationGroup.id)
Snackbar.show(message: "You have left the group.")
Snackbar.show(message: "You have left the club.")
} catch {
return false
}
@ -129,9 +129,9 @@ struct RecommendationGroupView: View {
Section("Members") {
if !viewModel.recommendationGroup.canSeeMembers {
Text("""
The admin of this group does not allow viewing all members.
The admin of this club does not allow viewing all members.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else if viewModel.nonAdmins.count > 0 {
@ -144,10 +144,10 @@ struct RecommendationGroupView: View {
}
} else {
Text("""
This group does not have any members. Add users to your group by sending
This club does not have any members. Add users to your club by sending
them the invite link.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
@ -160,7 +160,7 @@ struct RecommendationGroupView: View {
}
return AnyView(Button(action: {
viewModel.showLeaveGroup = true
}, label: { Text("Leave Group") })
}, label: { Text("Leave Club") })
.accentColor(.red))
}
@ -196,8 +196,8 @@ struct RecommendationGroupView: View {
}
.alert(isPresented: $viewModel.showLeaveGroup) {
Alert(
title: Text("Are you sure you want to leave this group? No data will be deleted, but you will stop receiving recommendations from the group."),
primaryButton: .destructive(Text("Leave Group")) {
title: Text("Are you sure you want to leave this club? No data will be deleted, but you will stop receiving recommendations from the club."),
primaryButton: .destructive(Text("Leave Club")) {
Task {
let success = await viewModel.leaveGroup(dataService: dataService)
if success {

View file

@ -40,7 +40,7 @@ import Views
await loadGroups(dataService: dataService)
showCreateSheet = false
} else {
createGroupError = "Error creating group"
createGroupError = "Error creating club"
showCreateError = true
}
@ -72,9 +72,9 @@ struct CreateRecommendationGroupView: View {
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name, prompt: Text("Group Name"))
TextField("Name", text: $name, prompt: Text("Club Name"))
Section {
Section("Club Rules") {
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
}
@ -82,7 +82,7 @@ struct CreateRecommendationGroupView: View {
Section {
Section {
Text("""
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
@ -99,7 +99,7 @@ struct CreateRecommendationGroupView: View {
}
}
.navigationViewStyle(.stack)
.navigationTitle("Create Group")
.navigationTitle("Create Club")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading:
Button(action: {
@ -132,7 +132,7 @@ struct GroupsView: View {
}
}
.task { await viewModel.loadGroups(dataService: dataService) }
.navigationTitle("Recommendation Groups")
.navigationTitle("Clubs")
}
private var innerBody: some View {
@ -143,7 +143,7 @@ struct GroupsView: View {
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text("Create a new group")
Text("Create a new club")
Spacer()
}
}
@ -152,7 +152,7 @@ struct GroupsView: View {
if !viewModel.isLoading {
if viewModel.recommendationGroups.count > 0 {
Section(header: Text("Your recommendation groups")) {
Section(header: Text("Your clubs")) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
let vm = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
NavigationLink(
@ -165,13 +165,13 @@ struct GroupsView: View {
} 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.
You are not a member of any clubs.
Create a new club and send the invite link to your friends get started.
During the beta you are limited to creating three groups, and each group
During the beta you are limited to creating three clubs, and each club
can have a maximum of twelve users.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}

View file

@ -15,7 +15,7 @@ public final class RootViewModel: ObservableObject {
let services = Services()
@Published public var showNewFeaturePrimer = false
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = true
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = false
@Published var snackbarMessage: String?
@Published var showSnackbar = false

View file

@ -29,9 +29,9 @@ import Views
do {
dataService.viewContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.createdAt), ascending: false)
fetchRequest.sortDescriptors = [sort]
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.name), ascending: true)
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
fetchRequest.sortDescriptors = [sort]
// If this fails we will fallback to making the API call
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
@ -41,7 +41,10 @@ import Views
self.recommendationGroups = groups
}
}
recommendationGroups = try await dataService.recommendationGroups().filter(\.canPost)
recommendationGroups = try await dataService.recommendationGroups()
.filter(\.canPost)
.sorted(by: { $0.name < $1.name })
} catch {
print("ERROR fetching recommendationGroups: ", error)
networkError = true
@ -160,15 +163,15 @@ struct RecommendToView: View {
List {
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
Text("""
You do not have any groups you can post to.
You do not have any clubs you can post to.
Join a group or create your own to start recommending articles.
Join a club or create your own to start recommending articles.
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else {
Section("Select groups to recommend to") {
Section("Select clubs to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)

View file

@ -6,6 +6,7 @@ import Utils
import Views
import WebKit
// swiftlint:disable:next type_body_length
struct WebReaderContainerView: View {
let item: LinkedItem
@ -16,7 +17,6 @@ struct WebReaderContainerView: View {
@State private var showHighlightsView = false
@State private var hasPerformedHighlightMutations = false
@State var showHighlightAnnotationModal = false
@State var safariWebLink: SafariWebLink?
@State private var navBarVisibilityRatio = 1.0
@State private var showDeleteConfirmation = false
@State private var progressViewOpacity = 0.0
@ -31,6 +31,10 @@ struct WebReaderContainerView: View {
@State private var showErrorAlertMessage = false
@State private var showRecommendSheet = false
@State var safariWebLink: SafariWebLink?
@State var displayLinkSheet = false
@State var linkToOpen: URL?
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@ -326,7 +330,12 @@ struct WebReaderContainerView: View {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
safariWebLink = SafariWebLink(id: UUID(), url: $0)
if UIDevice.current.userInterfaceIdiom == .phone, $0.absoluteString != item.unwrappedPageURLString {
linkToOpen = $0
displayLinkSheet = true
} else {
safariWebLink = SafariWebLink(id: UUID(), url: $0)
}
#endif
},
webViewActionHandler: webViewActionHandler,
@ -347,6 +356,22 @@ struct WebReaderContainerView: View {
showNavBarActionID = UUID()
}
}
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
Button(action: {
if let linkToOpen = linkToOpen {
safariWebLink = SafariWebLink(id: UUID(), url: linkToOpen)
}
}, label: { Text("Open") })
Button(action: {
UIPasteboard.general.string = item.unwrappedPageURLString
showInSnackbar("Link Copied")
}, label: { Text("Copy Link") })
Button(action: {
if let linkToOpen = linkToOpen {
viewModel.saveLink(dataService: dataService, url: linkToOpen)
}
}, label: { Text("Save to Omnivore") })
}
#if os(iOS)
.fullScreenCover(item: $safariWebLink) {
SafariView(url: $0.url)

View file

@ -195,4 +195,17 @@ struct SafariWebLink: Identifiable {
{
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labelIDs)
}
func saveLink(dataService: DataService, url: URL) {
Task {
do {
Snackbar.show(message: "Saving link")
print("SAVING: ", url.absoluteString)
_ = try await dataService.createPageFromUrl(id: UUID().uuidString, url: url.absoluteString)
Snackbar.show(message: "Link saved")
} catch {
Snackbar.show(message: "Error saving link")
}
}
}
}

View file

@ -15,10 +15,16 @@ public struct LinkedItemQueryResult {
public struct LinkedItemSyncResult {
public let updatedItemIDs: [String]
public let cursor: String?
public let hasMore: Bool
public let mostRecentUpdatedAt: Date?
public let isEmpty: Bool
public init(updatedItemIDs: [String], cursor: String?) {
public init(updatedItemIDs: [String], cursor: String?, hasMore: Bool, mostRecentUpdatedAt: Date?, isEmpty: Bool) {
self.updatedItemIDs = updatedItemIDs
self.cursor = cursor
self.hasMore = hasMore
self.mostRecentUpdatedAt = mostRecentUpdatedAt
self.isEmpty = isEmpty
}
}

View file

@ -4,7 +4,7 @@ import Models
import Utils
public extension DataService {
internal func syncOfflineItemsWithServerIfNeeded() async throws {
func syncOfflineItemsWithServerIfNeeded() async throws {
var unsyncedLinkedItems = [LinkedItem]()
var unsyncedHighlights = [Highlight]()

View file

@ -6,24 +6,12 @@ public extension DataService {
/// Requests `LinkedItem`s updates from the server since a certain datae
/// and stores it in CoreData while deleting all the items with ids the server says
/// have been deleted.
/// - Parameters:
/// - limit: max count of items
/// - searchQuery: search terms and filters
/// - cursor: cursor when loading batch for infinite list
/// - Returns: `LinkedItemQueryResult` (managed object IDs and an optional cursor)
func syncLinkedItems(
since date: Date,
since: Date,
cursor: String?,
previousQueryResult: LinkedItemSyncResult? = nil,
deferFetchingMore: Bool
) async throws -> LinkedItemSyncResult? {
if previousQueryResult == nil {
// Send offline changes to server before fetching items
// only on the first call of this function
try? await syncOfflineItemsWithServerIfNeeded()
}
let fetchResult = try await linkedItemUpdates(since: date, limit: 20, cursor: cursor)
descending: Bool = true
) async throws -> LinkedItemSyncResult {
let fetchResult = try await linkedItemUpdates(since: since, limit: 20, cursor: cursor, descending: descending)
LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
@ -31,32 +19,15 @@ public extension DataService {
throw BasicError.message(messageText: "CoreData error")
}
let prev = previousQueryResult?.updatedItemIDs ?? []
let newestChange = fetchResult.items.max { $0.updatedAt < $1.updatedAt }
let result = LinkedItemSyncResult(
updatedItemIDs: prev + fetchResult.items.map(\.id),
cursor: fetchResult.cursor
updatedItemIDs: fetchResult.items.map(\.id),
cursor: fetchResult.cursor,
hasMore: fetchResult.hasMoreItems,
mostRecentUpdatedAt: newestChange?.updatedAt,
isEmpty: fetchResult.deletedItemIDs.isEmpty && fetchResult.items.isEmpty
)
if fetchResult.hasMoreItems, (previousQueryResult?.updatedItemIDs.count ?? 0) < 40 {
if deferFetchingMore {
Task.detached(priority: .background) {
try await self.syncLinkedItems(
since: date,
cursor: fetchResult.cursor,
previousQueryResult: result,
deferFetchingMore: deferFetchingMore
)
}
} else {
return try await syncLinkedItems(
since: date,
cursor: fetchResult.cursor,
previousQueryResult: result,
deferFetchingMore: deferFetchingMore
)
}
}
return result
}
@ -104,4 +75,52 @@ public extension DataService {
let articleContent = try await loadArticleContentWithRetries(itemID: requestID, username: username, requestCount: 0)
return articleContent.objectID
}
// This will iterate through a paginated list of sync updates, and upon completing each one,
// update the lastItemSyncTime to the pages most recent change. This allows us to paginate
// very large sets of changes that could fail due to rate limiting or network failures.
// Eventually we should be able to work through the list of changes and catch up.
func syncLinkedItemsInBackground(
since: Date,
onComplete: @escaping () -> Void
) {
Task.detached(priority: .background) {
var count = 0
for try await result in BackgroundSync(dataService: self, since: since, cursor: nil) {
count += result.updatedItemIDs.count
if count > 180 {
break
}
}
DispatchQueue.main.sync {
self.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date.now)
onComplete()
}
}
}
}
struct BackgroundSync: AsyncSequence {
public typealias Element = LinkedItemSyncResult
public let dataService: DataService
public let since: Date
public let cursor: String?
public struct AsyncIterator: AsyncIteratorProtocol {
let dataService: DataService
public var since: Date
public var cursor: String?
public mutating func next() async throws -> LinkedItemSyncResult? {
let result = try await dataService.syncLinkedItems(since: since,
cursor: cursor,
descending: true)
cursor = result.cursor
return result.isEmpty ? nil : result
}
}
public func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(dataService: dataService, since: since, cursor: cursor)
}
}

View file

@ -13,6 +13,7 @@ struct InternalLinkedItemUpdatesQueryResult {
let deletedItemIDs: [String]
let cursor: String?
let hasMoreItems: Bool
let totalCount: Int
}
private struct SyncItemEdge {
@ -26,12 +27,14 @@ extension DataService {
func linkedItemUpdates(
since: Date,
limit: Int,
cursor: String?
cursor: String?,
descending: Bool = true
) async throws -> InternalLinkedItemUpdatesQueryResult {
struct QuerySuccessResult {
let edges: [SyncItemEdge]
let cursor: String?
let hasMoreItems: Bool
let totalCount: Int
}
enum QueryResult {
case success(result: QuerySuccessResult)
@ -55,6 +58,9 @@ extension DataService {
}),
hasMoreItems: try $0.pageInfo(selection: Selection.PageInfo {
try $0.hasNextPage()
}),
totalCount: try $0.pageInfo(selection: Selection.PageInfo {
try $0.totalCount() ?? -1
})
)
)
@ -62,7 +68,7 @@ extension DataService {
)
}
let sort = InputObjects.SortParams(by: Enums.SortBy.savedAt, order: OptionalArgument(Enums.SortOrder.descending))
let sort = InputObjects.SortParams(by: Enums.SortBy.updatedTime, order: OptionalArgument(descending ? Enums.SortOrder.descending : Enums.SortOrder.ascending))
let query = Selection.Query {
try $0.updatesSince(
@ -99,7 +105,8 @@ extension DataService {
items: items,
deletedItemIDs: deletedItemIDs,
cursor: result.cursor,
hasMoreItems: result.hasMoreItems
hasMoreItems: result.hasMoreItems,
totalCount: result.totalCount
)
)
case let .error(error):

View file

@ -26,4 +26,5 @@ public enum UserDefaultKey: String {
case shouldShowNewFeaturePrimer
case notificationsEnabled
case deviceTokenID
case shouldPromptCommunityModal
}

View file

@ -0,0 +1,121 @@
//
// CommunityModal.swift
//
//
// Created by Jackson Harper on 12/7/22.
//
import Foundation
import StoreKit
import SwiftUI
let tweetUrl = "https://twitter.com/intent/tweet?text=I%20recently%20started%20using%20@OmnivoreApp%20as%20a%20free,%20open-source%20read-it-later%20app.%20Check%20it%20out:%20https://omnivore.app"
public struct CommunityModal: View {
@Environment(\.dismiss) private var dismiss
let message: String = """
Thank you for being a member of the Omnivore Community.
Omnivore is a free and open-source project and relies on \
help from our community to grow. Below are a few simple \
things you can do to help us build a better Omnivore.
"""
public init() {}
// var body: some View {
// ZStack {
// Image("Biz-card_2020")
// .resizable()
// .edgesIgnoringSafeArea(.all)
// closeButton
// }
// }
var closeButton: some View {
VStack {
HStack {
Spacer()
Button(action: {
dismiss()
}) {
Image(systemName: "xmark.circle")
.padding(10)
}
}
.padding(.top, 5)
Spacer()
}
}
public var header: some View {
VStack(spacing: 0) {
Text("Help build the Omnivore Community")
.font(.textToSpeechRead)
.foregroundColor(Color.appGrayTextContrast)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
TextChip(text: "Help Wanted", color: Color.appBackground)
.frame(alignment: .leading)
TextChip(text: "Community", color: Color.green)
.frame(alignment: .leading)
}
.padding(.top, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
let links = [
(title: "Tweet about Omnivore", url: tweetUrl),
(title: "Follow us on Twitter", url: "https://twitter.com/omnivoreapp"),
(title: "Join us on Discord", url: "https://discord.gg/h2z5rppzz9"),
(title: "Star on GitHub", url: "https://github.com/omnivore-app/omnivore")
]
var buttonLinks: some View {
VStack(spacing: 15) {
Button(action: {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
}, label: { Text("Review on the AppStore") })
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(links, id: \.url) { link in
if let url = URL(string: link.url) {
Link(link.title, destination: url)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
}
public var body: some View {
VStack(spacing: 0) {
header
Text((try? AttributedString(markdown: message,
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? "")
.multilineTextAlignment(.leading)
.foregroundColor(Color.appGrayTextContrast)
.accentColor(.blue)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
Spacer()
buttonLinks
Spacer()
Button(action: {
dismiss()
}, label: { Text("Dismiss") })
.buttonStyle(PlainButtonStyle())
.padding(.bottom, 16)
.frame(alignment: .bottom)
}.padding()
}
}

View file

@ -19,7 +19,6 @@ public func registerFonts() -> Bool {
registerFont(bundle: .module, fontName: "Roboto-Regular", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "CrimsonText-Regular", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "OpenDyslexicAlta-Regular", fontExtension: "otf"),
registerFont(bundle: .module, fontName: "Georgia-Regular", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Montserrat-Regular", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "Newsreader-Regular", fontExtension: "ttf"),
registerFont(bundle: .module, fontName: "SourceSerifPro-Regular", fontExtension: "ttf")

View file

@ -53,15 +53,15 @@ public struct FeaturePrimer: View {
public static var recommendationsPrimer: some View {
FeaturePrimer(
isBeta: true,
title: "Introducing Recommendation Groups",
title: "Introducing Clubs",
message: """
Recommendation groups make it easy to share great reads with friends and co-workers.
Clubs 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.
To get started, create a club 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.*
*During the beta you can create a max of three clubs. Club sizes are limited to 12 people.*
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
"""
)

File diff suppressed because one or more lines are too long

View file

@ -8,25 +8,25 @@ import {
JoinColumn,
ManyToOne,
OneToMany,
} from 'typeorm';
import AdminJs from 'adminjs';
import { Database, Resource } from '@adminjs/typeorm';
} from 'typeorm'
import AdminJs from 'adminjs'
import { Database, Resource } from '@adminjs/typeorm'
export const registerDatabase = async (): Promise<Connection> => {
AdminJs.registerAdapter({ Database, Resource });
AdminJs.registerAdapter({ Database, Resource })
let host = 'localhost';
let host = 'localhost'
if (process.env.K_SERVICE) {
console.log(
'connecting to database via Cloud Run connection',
process.env.CLOUD_SQL_CONNECTION_NAME,
process.env.DB_NAME,
);
const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql';
host = `${dbSocketPath}/${process.env.CLOUD_SQL_CONNECTION_NAME}`;
process.env.DB_NAME
)
const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql'
host = `${dbSocketPath}/${process.env.CLOUD_SQL_CONNECTION_NAME}`
}
console.log('connecting to database:', host);
console.log('connecting to database:', host)
const connection = await createConnection({
type: 'postgres',
host: host,
@ -35,81 +35,78 @@ export const registerDatabase = async (): Promise<Connection> => {
password: process.env.DB_PASS,
database: process.env.DB_DATABASE,
entities: [AdminUser, User, UserProfile, UserArticle],
});
})
return connection;
};
return connection
}
@Entity({ name: 'admin_user' })
export class AdminUser extends BaseEntity {
@PrimaryGeneratedColumn()
public id!: string;
public id!: string
@Column({ type: 'text' })
public email!: string;
public email!: string
@Column({ type: 'text' })
public password!: string;
public password!: string
}
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
id!: string
@Column('text')
name!: string
@Column({ type: 'text' })
public first_name!: string;
@Column({ type: 'text' })
public last_name!: string;
@Column({ type: 'text' })
public email!: string;
public email!: string
@Column({ type: 'timestamp' })
public created_at!: Date;
public created_at!: Date
@Column({ type: 'timestamp' })
public updated_at!: Date;
public updated_at!: Date
@OneToMany(() => UserArticle, ua => ua.user)
articles!: UserArticle[];
@OneToMany(() => UserArticle, (ua) => ua.user)
articles!: UserArticle[]
}
@Entity()
export class UserProfile extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
id!: string
@Column({ type: 'text' })
public username!: string;
public username!: string
@JoinColumn({ name: 'user_id' })
@ManyToOne(() => User, user => user.articles, { eager: true })
user!: User;
@ManyToOne(() => User, (user) => user.articles, { eager: true })
user!: User
}
@Entity({ name: 'user_articles' })
export class UserArticle extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
id!: string
@JoinColumn({ name: 'user_id' })
@ManyToOne(() => User, user => user.articles, { eager: true })
user!: User;
@ManyToOne(() => User, (user) => user.articles, { eager: true })
user!: User
@Column({ type: 'text', name: 'article_id' })
articleId!: string;
articleId!: string
@Column({ type: 'text' })
slug!: string;
slug!: string
@Column({ type: 'timestamp', name: 'created_at' })
createdAt!: Date;
createdAt!: Date
@Column({ type: 'timestamp', name: 'updated_at' })
updatedAt!: Date;
updatedAt!: Date
@Column({ type: 'timestamp', name: 'saved_at' })
savedAt!: Date;
savedAt!: Date
}