diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index baba3253f..c470508b6 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -69,7 +69,6 @@ jobs: yarn build yarn lint yarn test - env: PG_HOST: localhost PG_PORT: ${{ job.services.postgres.ports[5432] }} @@ -78,3 +77,12 @@ jobs: PG_DB: omnivore_test PG_POOL_MAX: 10 ELASTIC_URL: http://localhost:${{ job.services.elastic.ports[9200] }}/ + build-docker-images: + name: Build docker images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Build the API docker image + run: 'docker build --file packages/api/Dockerfile .' diff --git a/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift new file mode 100644 index 000000000..ffb6214fb --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift @@ -0,0 +1,87 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class ApplyLabelsViewModel: ObservableObject { + private var hasLoadedInitialLabels = false + @Published var isLoading = true + @Published var selectedLabels = Set() + @Published var labels = [FeedItemLabel]() + + var subscriptions = Set() + + func load(item: FeedItem, dataService: DataService) { + guard !hasLoadedInitialLabels else { return } + + dataService.labelsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels = result + self?.hasLoadedInitialLabels = true + self?.selectedLabels = Set(item.labels) + } + ) + .store(in: &subscriptions) + } + + func saveChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { + dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink( + receiveCompletion: { _ in }, + receiveValue: { onComplete($0) } + ) + .store(in: &subscriptions) + } +} + +struct ApplyLabelsView: View { + let item: FeedItem + let commitLabelChanges: ([FeedItemLabel]) -> Void + + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) private var presentationMode + @StateObject var viewModel = ApplyLabelsViewModel() + + var body: some View { + NavigationView { + if viewModel.isLoading { + EmptyView() + } else { + List(viewModel.labels, id: \.self, selection: $viewModel.selectedLabels) { label in + if let textChip = TextChip(feedItemLabel: label) { + textChip + } else { + Text(label.name) + } + } + .environment(\.editMode, .constant(EditMode.active)) + .navigationTitle("Apply Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Text("Cancel") } + ) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { + viewModel.saveChanges(itemID: item.id, dataService: dataService) { labels in + commitLabelChanges(labels) + presentationMode.wrappedValue.dismiss() + } + }, + label: { Text("Save") } + ) + } + } + } + } + .onAppear { + viewModel.load(item: item, dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 63919c49c..9db48b14a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -36,7 +36,6 @@ struct GridCardNavigationLink: View { @EnvironmentObject var dataService: DataService @State private var scale = 1.0 - @State private var isActive = false let item: FeedItem let searchQuery: String @@ -51,7 +50,8 @@ struct GridCardNavigationLink: View { ZStack { NavigationLink( destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)), - isActive: $isActive + tag: item, + selection: $selectedLinkItem ) { EmptyView() } @@ -60,7 +60,7 @@ struct GridCardNavigationLink: View { scale = 0.95 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) { scale = 1.0 - isActive = true + selectedLinkItem = item } } }) @@ -68,7 +68,7 @@ struct GridCardNavigationLink: View { viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) } } - .aspectRatio(2.1, contentMode: .fill) + .aspectRatio(1.8, contentMode: .fill) .scaleEffect(scale) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 4c29dd93b..535eb577e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -100,6 +100,9 @@ import Views viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) } } + .onChange(of: selectedLinkItem) { _ in + viewModel.commitProgressUpdates() + } } } @@ -164,6 +167,11 @@ import Views } } } + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(item: item) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) + } + } } } } @@ -320,13 +328,15 @@ import Views case .delete: itemToRemove = item confirmationShown = true + case .editLabels: + viewModel.itemUnderLabelEdit = item } } var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) { - ForEach(viewModel.items, id: \.renderID) { item in + ForEach(viewModel.items) { item in let link = GridCardNavigationLink( item: item, searchQuery: searchQuery, diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index e0ebabb8d..f5e304094 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -8,9 +8,13 @@ import Views final class HomeFeedViewModel: ObservableObject { var currentDetailViewModel: LinkItemDetailViewModel? + /// Track progress updates to be committed when user navigates back to grid view + var uncommittedReadingProgressUpdates = [String: Double]() + @Published var items = [FeedItem]() @Published var isLoading = false @Published var showPushNotificationPrimer = false + @Published var itemUnderLabelEdit: FeedItem? var cursor: String? var sendProgressUpdates = false @@ -76,6 +80,9 @@ final class HomeFeedViewModel: ObservableObject { if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { return } + + dataService.prefetchPages(items: result.items) + self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items self?.isLoading = false self?.receivedIdx = thisSearchIdx @@ -160,10 +167,27 @@ final class HomeFeedViewModel: ObservableObject { .store(in: &subscriptions) } - func updateProgress(itemID: String, progress: Double) { + /// Update `FeedItem`s with the cached reading progress values so it can animate when the + /// user navigates back to the grid view (and also avoid mutations of the grid items + /// that can cause the `NavigationView` to pop. + func commitProgressUpdates() { + for (key, value) in uncommittedReadingProgressUpdates { + updateProgress(itemID: key, progress: value) + } + uncommittedReadingProgressUpdates = [:] + } + + private func updateProgress(itemID: String, progress: Double) { guard sendProgressUpdates, let item = items.first(where: { $0.id == itemID }) else { return } if let index = items.firstIndex(of: item) { items[index].readingProgress = progress } } + + func updateLabels(itemID: String, labels: [FeedItemLabel]) { + guard let item = items.first(where: { $0.id == itemID }) else { return } + if let index = items.firstIndex(of: item) { + items[index].labels = labels + } + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 06f94ea3c..2d83208f3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -92,7 +92,7 @@ final class LinkItemDetailViewModel: ObservableObject { case let .shareHighlight(highlightID): print("show share modal for highlight with id: \(highlightID)") case let .updateReadingProgess(progress: progress): - self?.homeFeedViewModel.updateProgress(itemID: self?.item.id ?? "", progress: Double(progress)) + self?.homeFeedViewModel.uncommittedReadingProgressUpdates[self?.item.id ?? ""] = Double(progress) } } .store(in: &newWebAppWrapperViewModel.subscriptions) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift new file mode 100644 index 000000000..852b64e8c --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift @@ -0,0 +1,195 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class LabelsViewModel: ObservableObject { + private var hasLoadedInitialLabels = false + @Published var isLoading = false + @Published var labels = [FeedItemLabel]() + @Published var showCreateEmailModal = false + + var subscriptions = Set() + + func loadLabels(dataService: DataService) { + guard !hasLoadedInitialLabels else { return } + isLoading = true + + dataService.labelsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels = result + self?.hasLoadedInitialLabels = true + } + ) + .store(in: &subscriptions) + } + + func createLabel(dataService: DataService, name: String, color: Color, description: String?) { + isLoading = true + + dataService.createLabelPublisher( + name: name, + color: color.hex ?? "", + description: description + ).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels.insert(result, at: 0) + self?.showCreateEmailModal = false + } + ) + .store(in: &subscriptions) + } + + func deleteLabel(dataService: DataService, labelID: String) { + isLoading = true + + dataService.removeLabelPublisher(labelID: labelID).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + self?.labels.removeAll { $0.id == labelID } + } + ) + .store(in: &subscriptions) + } +} + +struct LabelsView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = LabelsViewModel() + @State private var showDeleteConfirmation = false + @State private var labelToRemoveID: String? + + let footerText = "Use labels to create curated collections of links." + + var body: some View { + Group { + #if os(iOS) + if #available(iOS 15.0, *) { + Form { + innerBody + .alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) { + Button("Remove Link", role: .destructive) { + if let labelID = labelToRemoveID { + withAnimation { + viewModel.deleteLabel(dataService: dataService, labelID: labelID) + } + } + self.labelToRemoveID = nil + } + Button("Cancel", role: .cancel) { self.labelToRemoveID = nil } + } + } + } else { + Form { innerBody } + } + + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .onAppear { viewModel.loadLabels(dataService: dataService) } + } + + private var innerBody: some View { + Group { + Section(footer: Text(footerText)) { + Button( + action: { viewModel.showCreateEmailModal = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new Label") + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + + if !viewModel.labels.isEmpty { + Section(header: Text("Labels")) { + ForEach(viewModel.labels, id: \.id) { label in + HStack { + Text(label.name) + Spacer() + Button( + action: { + labelToRemoveID = label.id + showDeleteConfirmation = true + }, + label: { Image(systemName: "trash") } + ) + } + } + } + } + } + .navigationTitle("Labels") + .sheet(isPresented: $viewModel.showCreateEmailModal) { + CreateLabelView(viewModel: viewModel) + } + } +} + +struct CreateLabelView: View { + @EnvironmentObject var dataService: DataService + @ObservedObject var viewModel: LabelsViewModel + + @State private var newLabelName = "" + @State private var newLabelColor = Color.clear + + var body: some View { + NavigationView { + VStack(spacing: 16) { + TextField("Label Name", text: $newLabelName) + .keyboardType(.alphabet) + .textFieldStyle(StandardTextFieldStyle()) + ColorPicker( + newLabelColor == .clear ? "Select Color" : newLabelColor.description, + selection: $newLabelColor + ) + Button( + action: { + viewModel.createLabel( + dataService: dataService, + name: newLabelName, + color: newLabelColor, + description: nil + ) + }, + label: { Text("Create") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + .disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear) + Spacer() + } + .padding() + .toolbar { + ToolbarItem(placement: .automatic) { + Button( + action: { viewModel.showCreateEmailModal = false }, + label: { + Image(systemName: "xmark") + .foregroundColor(.appGrayTextContrast) + } + ) + } + } + .navigationTitle("Create New Label") + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index ca2443130..80d6eef94 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -63,6 +63,12 @@ struct ProfileView: View { } Section { + if FeatureFlag.enableLabels { + NavigationLink(destination: LabelsView()) { + Text("Labels") + } + } + NavigationLink(destination: NewsletterEmailsView()) { Text("Emails") } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index 83e07696a..26ee03a8d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -29,17 +29,6 @@ struct WebReader: UIViewRepresentable { let webView = WebViewManager.shared() let contentController = WKUserContentController() - webView.loadHTMLString( - WebReaderContent( - articleContent: articleContent, - item: item, - isDark: UITraitCollection.current.userInterfaceStyle == .dark, - fontSize: fontSize() - ) - .styledContent, - baseURL: ViewsPackage.bundleURL - ) - webView.navigationDelegate = context.coordinator webView.isOpaque = false webView.backgroundColor = .clear @@ -63,6 +52,7 @@ struct WebReader: UIViewRepresentable { context.coordinator.linkHandler = openLinkAction context.coordinator.webViewActionHandler = webViewActionHandler context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater + loadContent(webView: webView) return webView } @@ -82,5 +72,39 @@ struct WebReader: UIViewRepresentable { context.coordinator.previousDecreaseFontActionID = decreaseFontActionID (webView as? WebView)?.decreaseFontSize() } + + // If the webview had been terminated `needsReload` will have been set to true + if context.coordinator.needsReload { + loadContent(webView: webView) + context.coordinator.needsReload = false + return + } + + if webView.isLoading { return } + + // If the root element is not detected then `WKWebView` may have unloaded the content + // so we need to load it again. + webView.evaluateJavaScript("document.getElementById('root') ? true : false") { hasRootElement, _ in + guard let hasRootElement = hasRootElement as? Bool else { return } + + if !hasRootElement { + DispatchQueue.main.async { + loadContent(webView: webView) + } + } + } + } + + func loadContent(webView: WKWebView) { + webView.loadHTMLString( + WebReaderContent( + articleContent: articleContent, + item: item, + isDark: UITraitCollection.current.userInterfaceStyle == .dark, + fontSize: fontSize() + ) + .styledContent, + baseURL: ViewsPackage.bundleURL + ) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 503f67417..51bc8fca4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -36,7 +36,7 @@ struct WebReaderContainerView: View { let messageBody = message.body as? [String: Double] if let messageBody = messageBody, let progress = messageBody["progress"] { - homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress)) + homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) } } @@ -56,7 +56,7 @@ struct WebReaderContainerView: View { if message.name == WebViewAction.readingProgressUpdate.rawValue { guard let messageBody = message.body as? [String: Double] else { return } guard let progress = messageBody["progress"] else { return } - homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress)) + homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) } } @@ -204,7 +204,7 @@ struct WebReaderContainerView: View { Color.systemBackground .transition(.opacity) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { withAnimation(.linear(duration: 0.2)) { showOverlay = false } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift index 8be76c50d..1f0b813ea 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift @@ -22,7 +22,11 @@ struct WebReaderContent { // swiftlint:disable line_length var styledContent: String { - """ + let savedAt = "new Date(\(item.savedAt.timeIntervalSince1970 * 1000)).toISOString()" + let createdAt = "new Date(\(item.createdAt.timeIntervalSince1970 * 1000)).toISOString()" + let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined" + + return """ @@ -37,9 +41,6 @@ struct WebReaderContent { -