├── TestData └── .keep ├── .swiftlint.yml ├── Den ├── AppAssets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ ├── icon_16x16.png │ │ ├── icon_32x32.png │ │ ├── icon_128x128.png │ │ ├── icon_256x256.png │ │ ├── icon_512x512.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_512x512@2x.png │ │ ├── icon_32x32@2x@2x.png │ │ ├── Icon-Dark-1024×1024.png │ │ ├── Icon-Light-1024×1024.png │ │ └── Icon-Tinted-1024×1024.png ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── UI │ ├── Enumerations │ │ ├── OperatingSystem.swift │ │ ├── BookmarksLayout.swift │ │ ├── ViewerOption.swift │ │ ├── PageLayout.swift │ │ ├── RefreshInterval.swift │ │ ├── UserColorScheme.swift │ │ ├── PageZoomLevel.swift │ │ └── WebAddressValidationMessage.swift │ ├── QuickActionManager.swift │ ├── BackgroundSeparatorShapeStyle.swift │ ├── SceneDelegate.swift │ ├── Styles │ │ ├── CompactContentUnavailableLabelStyle.swift │ │ ├── ContentUnavailableLabelStyle.swift │ │ ├── ThinLinearProgressViewStyle.swift │ │ ├── ContentBlockButtonStyle.swift │ │ ├── RefreshProgressViewStyle.swift │ │ └── FeedTitleButtonStyle.swift │ ├── Modifiers │ │ ├── ImageBorderModifier.swift │ │ ├── DraggableFeedModifier.swift │ │ ├── DraggableItemModifier.swift │ │ ├── DraggableBookmarkModifier.swift │ │ ├── SafeAreaModifier.swift │ │ └── PreviewImageStateModifier.swift │ ├── DenWebView.swift │ ├── NetworkMonitor.swift │ ├── NavigationStore.swift │ ├── AppDelegate.swift │ ├── BrowserDownload.swift │ ├── Columnizer.swift │ ├── WebViewError.swift │ ├── MercuryObject.swift │ └── DropDelegates │ │ └── BookmarksNavDropDelegate.swift ├── Extensions │ ├── FeedKit │ │ ├── WebFeed.swift │ │ ├── WebFeedItem.swift │ │ ├── FeedKitFeed+Extensions.swift │ │ ├── AtomFeedEntry+Extensions.swift │ │ ├── RSSFeed+Extensions.swift │ │ ├── JSONFeed+Extensions.swift │ │ ├── JSONFeedItem+Extensions.swift │ │ ├── RSSFeedItem+Extensions.swift │ │ └── AtomFeed+Extensions.swift │ ├── URL+Extensions.swift │ ├── Array+Extensions.swift │ ├── UIApplication+Extensions.swift │ ├── Color+Extensions.swift │ ├── Bundle+Extensions.swift │ ├── Set+Extensions.swift │ └── CaseIterable+Extensions.swift ├── Misc │ └── ParseForReader.js ├── Views │ ├── Preview │ │ ├── PreviewAuthor.swift │ │ ├── PreviewTeaser.swift │ │ ├── PreviewHeadline.swift │ │ ├── PreviewDateline.swift │ │ └── SmallThumbnail.swift │ ├── Bookmark │ │ ├── BookmarkFaviconPlaceholder.swift │ │ ├── BookmarksSpreadLayout.swift │ │ ├── BookmarksLayoutPicker.swift │ │ ├── UnbookmarkButton.swift │ │ └── Bookmarks.swift │ ├── Feed │ │ ├── FeedFaviconPlaceholder.swift │ │ ├── FeedTitleLabel.swift │ │ ├── NewFeedButton.swift │ │ ├── FeedNavLink.swift │ │ ├── DeleteFeedButton.swift │ │ ├── FeedEmpty.swift │ │ ├── FeedHero.swift │ │ └── FeedLayoutSection.swift │ ├── General │ │ ├── ShareButton.swift │ │ ├── ButtonChevron.swift │ │ ├── DeleteLabel.swift │ │ ├── ImageErrorPlaceholder.swift │ │ ├── CommonStatus.swift │ │ ├── CopyLinkButton.swift │ │ ├── GoToFeedNavLink.swift │ │ ├── ImageDepression.swift │ │ ├── OpenValidatorButton.swift │ │ ├── IconSelectorButton.swift │ │ ├── SystemBrowserButton.swift │ │ ├── AppErrorSheet.swift │ │ ├── SafariViewButton.swift │ │ ├── PagePicker.swift │ │ ├── NoFeeds.swift │ │ ├── BoardView.swift │ │ ├── WithTrends.swift │ │ ├── ToggleReadFilterButton.swift │ │ ├── AllRead.swift │ │ ├── RelativeRefreshedDate.swift │ │ ├── MarkAllReadUnreadButton.swift │ │ ├── InspectorToggleButton.swift │ │ └── Favicon.swift │ ├── Browser │ │ ├── BrowserNavControlGroup.swift │ │ ├── ReaderViewMenu.swift │ │ ├── DownloadsButton.swift │ │ ├── GoBackButton.swift │ │ ├── GoForwardButton.swift │ │ ├── ShowInFinderButton.swift │ │ ├── ToggleReaderButton.swift │ │ ├── ToggleJavaScriptButton.swift │ │ ├── ToggleBlocklistsButton.swift │ │ └── StopReloadButton.swift │ ├── Sidebar │ │ ├── ImportButton.swift │ │ ├── ExportButton.swift │ │ ├── OrganizerButton.swift │ │ ├── SettingsButton.swift │ │ ├── BookmarksNavLink.swift │ │ ├── RefreshButton.swift │ │ ├── InboxNavLink.swift │ │ ├── TrendingNavLink.swift │ │ ├── Start.swift │ │ ├── SidebarStatus.swift │ │ ├── MacSidebarBottomBar.swift │ │ └── SidebarFeed.swift │ ├── Navigation │ │ └── Welcome.swift │ ├── Item │ │ ├── ItemMeta.swift │ │ ├── ToggleReadButton.swift │ │ ├── ItemPreviewCompressed.swift │ │ ├── ItemPreviewExpanded.swift │ │ ├── FeedItemCompressed.swift │ │ ├── FeedItemExpanded.swift │ │ └── ToggleBookmarkedButton.swift │ ├── Page │ │ ├── NewPageButton.swift │ │ ├── DeletePageButton.swift │ │ ├── PageLayoutPicker.swift │ │ ├── TimelineLayout.swift │ │ ├── FeedItemGroup.swift │ │ ├── GroupedLayout.swift │ │ └── DeckColumn.swift │ ├── Inbox │ │ └── InboxLayout.swift │ ├── Settings │ │ ├── AppearanceSection.swift │ │ ├── UserColorSchemePicker.swift │ │ ├── SettingsSheet.swift │ │ └── BlocklistStatusView.swift │ ├── Trending │ │ ├── Trending.swift │ │ └── TrendLayout.swift │ └── Organizer │ │ └── OrganizerRowStatus.swift ├── Data │ ├── FeedURLResponse.swift │ ├── Ingest │ │ └── WebpageMetadata.swift │ ├── ImageMIMEType.swift │ ├── Transferable │ │ ├── TransferableFeed.swift │ │ ├── TransferableItem.swift │ │ └── TransferableBookmark.swift │ ├── WorkingTrend.swift │ ├── OPMLFile.swift │ ├── Tasks │ │ ├── CleanupTask.swift │ │ └── MaintenanceTask.swift │ └── BlocklistManifest.swift ├── Utilities │ ├── PasteboardUtility.swift │ ├── MediaTypeUtility.swift │ ├── CleanupUtility.swift │ └── CrashUtility.swift ├── PrivacyInfo.xcprivacy └── Den.entitlements ├── Shared ├── SharedAssets.xcassets │ ├── Contents.json │ └── Coral.colorset │ │ └── Contents.json ├── PreliminaryImage.swift ├── Extensions │ ├── Notification+Extensions.swift │ ├── UserDefaults+Extensions.swift │ ├── Logger+Extensions.swift │ ├── Collection+Extensions.swift │ └── FileManager+Extensions.swift ├── Entities │ ├── Profile+CoreDataClass.swift │ ├── History+CoreDataClass.swift │ ├── TrendItem+CoreDataClass.swift │ ├── Search+CoreDataClass.swift │ ├── BlocklistStatus+CoreDataClass.swift │ ├── Tag+CoreDataClass.swift │ ├── Blocklist+CoreDataClass.swift │ ├── Trend+CoreDataClass.swift │ └── FeedData+CoreDataClass.swift └── Enumerations │ ├── AppGroup.swift │ └── PreviewStyle.swift ├── WebExtension ├── Resources │ ├── images │ │ ├── icon-48.png │ │ ├── icon-64.png │ │ ├── icon-96.png │ │ ├── icon-128.png │ │ ├── icon-256.png │ │ ├── icon-512.png │ │ ├── toolbar-icon-16.png │ │ ├── toolbar-icon-19.png │ │ ├── toolbar-icon-32.png │ │ ├── toolbar-icon-38.png │ │ ├── toolbar-icon-48.png │ │ └── toolbar-icon-72.png │ ├── popup.html │ ├── _locales │ │ └── en │ │ │ └── messages.json │ ├── background.js │ └── manifest.json ├── SafariExtension.entitlements ├── SafariWebExtensionHandler.swift ├── Info.plist └── InfoPlist.xcstrings ├── WidgetExtension ├── WidgetAssets.xcassets │ ├── Contents.json │ ├── WidgetIcon.imageset │ │ ├── widget-icon.png │ │ └── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Info.plist ├── WidgetExtensionBundle.swift ├── LatestItems │ ├── LatestItemsConfigurationIntent.swift │ ├── LatestItemsEntry.swift │ └── LatestItemsWidget.swift ├── WidgetExtension.entitlements ├── InfoPlist.xcstrings ├── SDWebImageManager+Extensions.swift └── Common │ └── WidgetAppIcon.swift ├── Den.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── IDETemplateMacros.plist ├── .gitignore ├── Scripts ├── RemoveSupportFiles.sh └── SaveTestData.sh ├── ci_scripts └── ci_post_clone.sh ├── UITests ├── ImportExportUITests.swift ├── XCUIDevice+Extensions.swift ├── BookmarksUITests.swift ├── SidebarUITests.swift ├── UIDeviceOrientation+Extensions.swift ├── XCUIElement+Extensions.swift ├── SettingsUITests.swift ├── AppLaunchUITests.swift └── ItemUITests.swift ├── crowdin.yml └── TestPlans ├── Default.xctestplan └── MacComprehensive.xctestplan /TestData/.keep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - inclusive_language 3 | - trailing_whitespace 4 | - opening_brace 5 | -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Shared/SharedAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WebExtension/Resources/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/icon-48.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/icon-64.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/icon-96.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/icon-128.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/icon-256.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/icon-512.png -------------------------------------------------------------------------------- /WidgetExtension/WidgetAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Den/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WebExtension/Resources/images/toolbar-icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/toolbar-icon-16.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/toolbar-icon-19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/toolbar-icon-19.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/toolbar-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/toolbar-icon-32.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/toolbar-icon-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/toolbar-icon-38.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/toolbar-icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/toolbar-icon-48.png -------------------------------------------------------------------------------- /WebExtension/Resources/images/toolbar-icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WebExtension/Resources/images/toolbar-icon-72.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/Icon-Dark-1024×1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/Icon-Dark-1024×1024.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/Icon-Light-1024×1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/Icon-Light-1024×1024.png -------------------------------------------------------------------------------- /Den/AppAssets.xcassets/AppIcon.appiconset/Icon-Tinted-1024×1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/Den/AppAssets.xcassets/AppIcon.appiconset/Icon-Tinted-1024×1024.png -------------------------------------------------------------------------------- /WidgetExtension/WidgetAssets.xcassets/WidgetIcon.imageset/widget-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garrettrayj/den/HEAD/WidgetExtension/WidgetAssets.xcassets/WidgetIcon.imageset/widget-icon.png -------------------------------------------------------------------------------- /Den.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | xcuserdata/ 4 | localazy.keys.json 5 | Scripts/Logs/ 6 | TestData/* 7 | !TestData/.keep 8 | default.profraw 9 | fastlane/beta 10 | fastlane/screenshots 11 | fastlane/report.xml 12 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetAssets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Scripts/RemoveSupportFiles.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | rm -rf ~/Library/Group\ Containers/group.net.devsci.den 4 | rm -rf ~/Library/Containers/net.devsci.den 5 | rm -rf ~/Library/Containers/net.devsci.den.webext 6 | rm -rf ~/Library/Containers/net.devsci.den.widgets 7 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetAssets.xcassets/WidgetIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "widget-icon.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/OperatingSystem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OperatingSystem.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/28/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | enum OperatingSystem { 10 | case iOS 11 | case macOS 12 | } 13 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/WebFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebFeed.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/2/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol WebFeed { 12 | var webpage: URL? { get } 13 | } 14 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/WebFeedItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebFeedItem.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/31/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol WebFeedItem { 12 | var linkURL: URL? { get } 13 | } 14 | -------------------------------------------------------------------------------- /Den.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/BookmarksLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/20/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum BookmarksLayout: Int { 12 | case previews = 0 13 | case list = 1 14 | } 15 | -------------------------------------------------------------------------------- /Shared/PreliminaryImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreliminaryImage.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/31/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct PreliminaryImage { 12 | var url: URL 13 | var width: Int? 14 | var height: Int? 15 | } 16 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/ViewerOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewerOption.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/18/24. 6 | // Copyright © 2024 7 | // 8 | 9 | import Foundation 10 | 11 | enum ViewerOption: String { 12 | case builtInViewer 13 | case webBrowser 14 | #if os(iOS) 15 | case safariView 16 | #endif 17 | } 18 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/PageLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/28/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum PageLayout: Int, CaseIterable { 12 | case grouped = 0 13 | case deck = 1 14 | case timeline = 2 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Extensions/Notification+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/13/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Notification.Name { 12 | static let appErrored = Notification.Name("app-errored") 13 | } 14 | -------------------------------------------------------------------------------- /WebExtension/SafariExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /WidgetExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Den/Misc/ParseForReader.js: -------------------------------------------------------------------------------- 1 | function parseForReader() { 2 | Mercury.parse(window.location.href).then(function(result) { 3 | window.webkit.messageHandlers.reader.postMessage(JSON.stringify(result)); 4 | }); 5 | } 6 | 7 | window.addEventListener("pageshow", function(event) { 8 | if (event.persisted) { 9 | parseForReader(); 10 | } 11 | }); 12 | 13 | parseForReader(); 14 | -------------------------------------------------------------------------------- /WebExtension/Resources/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /ci_scripts/ci_post_clone.sh: -------------------------------------------------------------------------------- 1 | # 2 | # ci_post_clone.swift 3 | # Den 4 | # 5 | # Created by Garrett Johnson on 8/2/24. 6 | # Copyright © 2024 7 | # 8 | 9 | # Set the -e flag to stop running the script in case a command returns a nonzero exit code. 10 | set -e 11 | 12 | # Disable build tool plugin validation. 13 | defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES 14 | -------------------------------------------------------------------------------- /Shared/Extensions/UserDefaults+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/3/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension UserDefaults { 12 | static var group: UserDefaults { 13 | return UserDefaults(suiteName: AppGroup.den.rawValue)! 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Den/Views/Preview/PreviewAuthor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewAuthor.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/13/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PreviewAuthor: View { 12 | let author: String 13 | 14 | var body: some View { 15 | Text(author).font(.footnote.italic()).lineLimit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Den/Views/Bookmark/BookmarkFaviconPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkFaviconPlaceholder.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BookmarkFaviconPlaceholder: View { 12 | var body: some View { 13 | Image(systemName: "bookmark").foregroundStyle(.primary) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Den/Views/Feed/FeedFaviconPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedFaviconPlaceholder.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/6/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedFaviconPlaceholder: View { 12 | var body: some View { 13 | Image(systemName: "dot.radiowaves.up.forward").foregroundStyle(.primary) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Den/Views/General/ShareButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/8/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ShareButton: View { 12 | let item: URL 13 | 14 | var body: some View { 15 | ShareLink(item: item).help(Text("Share", comment: "Button help text.")) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Den/Views/Preview/PreviewTeaser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewTeaser.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/13/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PreviewTeaser: View { 12 | let teaser: String 13 | 14 | var body: some View { 15 | Text(teaser).font(.subheadline.leading(.tight)).lineLimit(4) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetExtensionBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetExtensionBundle.swift 3 | // WidgetExtension 4 | // 5 | // Created by Garrett Johnson on 4/28/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | import SwiftUI 11 | 12 | @main 13 | struct WidgetExtensionBundle: WidgetBundle { 14 | var body: some Widget { 15 | LatestItemsWidget() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Den/UI/QuickActionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickActionManager.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/2/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | @MainActor 13 | final class QuickActionManager { 14 | static let shared = QuickActionManager() 15 | 16 | var shortcutItem: UIApplicationShortcutItem? 17 | } 18 | #endif 19 | -------------------------------------------------------------------------------- /Den/Views/Preview/PreviewHeadline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewHeadline.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 4/30/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PreviewHeadline: View { 12 | let title: Text 13 | 14 | var body: some View { 15 | title.font(.headline).lineLimit(6).fixedSize(horizontal: false, vertical: true) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /WebExtension/SafariWebExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariWebExtensionHandler.swift 3 | // WebExtension 4 | // 5 | // Created by Garrett Johnson on 5/15/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SafariServices 10 | 11 | class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { 12 | func beginRequest(with context: NSExtensionContext) { 13 | return 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Den/Data/FeedURLResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedURLResponse.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/9/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct FeedURLResponse { 12 | var responseTime: Float = 0 13 | var statusCode: Int16 = 0 14 | var server: String? 15 | var cacheControl: String? 16 | var age: String? 17 | var eTag: String? 18 | } 19 | -------------------------------------------------------------------------------- /Shared/Entities/Profile+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Profile+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/30/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | @objc(Profile) 13 | /// Profiles have been deprecated. This class is here because I don't know what CoreData will do without it. 14 | final public class Profile: NSManagedObject { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Shared/SharedAssets.xcassets/Coral.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x50", 9 | "green" : "0x7F", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Den/Data/Ingest/WebpageMetadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebpageMetadata.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/4/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class WebpageMetadata { 12 | var favicons: [PreliminaryImage] = [] 13 | var icons: [PreliminaryImage] = [] 14 | var banners: [PreliminaryImage] = [] 15 | var description: String? 16 | var copyright: String? 17 | } 18 | -------------------------------------------------------------------------------- /Den/Extensions/URL+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/29/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension URL { 12 | var absoluteStringForNewFeed: String { 13 | self.absoluteString 14 | .replacingOccurrences(of: "feed:", with: "") 15 | .replacingOccurrences(of: "den:", with: "") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Den/Views/General/ButtonChevron.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonChevron.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/5/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ButtonChevron: View { 12 | var body: some View { 13 | Image(systemName: "chevron.forward") 14 | .font(.body.weight(.semibold)) 15 | .imageScale(.small) 16 | .foregroundStyle(.fill) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Den/Extensions/Array+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Array { 12 | func chunked(by chunkSize: Int) -> [[Element]] { 13 | return stride(from: 0, to: self.count, by: chunkSize).map { 14 | Array(self[$0.. 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.Safari.web-extension 9 | NSExtensionPrincipalClass 10 | $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Den.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // ___FILEBASENAME___.swift 8 | // ___PACKAGENAME___ 9 | // 10 | // Created by ___FULLUSERNAME___ on ___DATE___. 11 | // Copyright © ___YEAR___ ___ORGANIZATIONNAME___. All rights reserved. 12 | // 13 | 14 | -------------------------------------------------------------------------------- /UITests/ImportExportUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImportExportUITests.swift 3 | // UI Tests 4 | // 5 | // Created by Garrett Johnson on 11/1/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class ImportExportUITests: UITestCase { 12 | @MainActor 13 | func testImport() throws { 14 | throw XCTSkip("Missing test") 15 | } 16 | 17 | @MainActor 18 | func testExport() throws { 19 | throw XCTSkip("Missing test") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Den/Extensions/UIApplication+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplication+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/18/24. 6 | // Copyright © 2024 7 | // 8 | 9 | #if !os(macOS) 10 | import UIKit 11 | 12 | extension UIApplication { 13 | var firstKeyWindow: UIWindow? { 14 | return UIApplication.shared.connectedScenes 15 | .compactMap { $0 as? UIWindowScene } 16 | .filter { $0.activationState == .foregroundActive } 17 | .first?.keyWindow 18 | } 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /Shared/Extensions/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/22/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Collection where Element: Equatable { 12 | func uniqueElements() -> [Element] { 13 | var out = [Element]() 14 | for element in self where !out.contains(element) { 15 | out.append(element) 16 | } 17 | 18 | return out 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Den/Views/General/DeleteLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteLabel.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/11/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DeleteLabel: View { 12 | var symbol = "trash" 13 | 14 | var body: some View { 15 | Label { 16 | Text("Delete", comment: "Button label.") 17 | } icon: { 18 | Image(systemName: symbol) 19 | } 20 | .symbolRenderingMode(.multicolor) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Den/Views/General/ImageErrorPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageErrorPlaceholder.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/30/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ImageErrorPlaceholder: View { 12 | var body: some View { 13 | Image(systemName: "photo") 14 | .imageScale(.large) 15 | .foregroundStyle(.fill.secondary) 16 | .padding() 17 | .frame(maxWidth: .infinity, maxHeight: .infinity) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Shared/Entities/History+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // History+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/16/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | @objc(History) 12 | final public class History: NSManagedObject { 13 | static func create(in managedObjectContext: NSManagedObjectContext) -> History { 14 | let history = self.init(context: managedObjectContext) 15 | history.id = UUID() 16 | 17 | return history 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Den/UI/BackgroundSeparatorShapeStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundSeparatorShapeStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/12/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BackgroundSeparatorShapeStyle: ShapeStyle { 12 | func resolve(in environment: EnvironmentValues) -> some ShapeStyle { 13 | if environment.colorScheme == .light { 14 | return Color.gray.opacity(0.3) 15 | } else { 16 | return Color.black.opacity(0.5) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Shared/Enumerations/AppGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppGroup.swift 3 | // Shared 4 | // 5 | // Created by Garrett Johnson on 4/29/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum AppGroup: String { 12 | case den = "group.net.devsci.den" 13 | 14 | public var containerURL: URL? { 15 | switch self { 16 | case .den: 17 | return FileManager.default.containerURL( 18 | forSecurityApplicationGroupIdentifier: self.rawValue 19 | ) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /UITests/XCUIDevice+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIDevice+Extensions.swift 3 | // UITests 4 | // 5 | // Created by Garrett Johnson on 7/18/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCUIDevice.Appearance { 12 | var name: String { 13 | switch self { 14 | case .unspecified: 15 | "Unspecified" 16 | case .light: 17 | "Light" 18 | case .dark: 19 | "Dark" 20 | @unknown default: 21 | "Default" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Den/Utilities/PasteboardUtility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PasteboardUtility.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/23/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PasteboardUtility { 12 | static func copyURL(url: URL) { 13 | #if os(macOS) 14 | NSPasteboard.general.prepareForNewContents() 15 | NSPasteboard.general.setString(url.absoluteString, forType: .string) 16 | #else 17 | UIPasteboard.general.string = url.absoluteString 18 | #endif 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Den/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPITypeReasons 9 | 10 | CA92.1 11 | 12 | NSPrivacyAccessedAPIType 13 | NSPrivacyAccessedAPICategoryUserDefaults 14 | 15 | 16 | NSPrivacyTracking 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/RefreshInterval.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshInterval.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/7/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum RefreshInterval: Int, CaseIterable { 12 | #if DEBUG 13 | case oneSecond = 1 14 | #endif 15 | case zero = 0 16 | case halfHour = 1800 17 | case oneHour = 3600 18 | case twoHours = 7200 19 | case threeHours = 10800 20 | case sixHours = 21600 21 | case twelveHours = 43200 22 | case twentyFourHours = 86400 23 | } 24 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/UserColorScheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Appearance.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/15/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | enum UserColorScheme: Int { 12 | case system = 0 13 | case light = 1 14 | case dark = 2 15 | 16 | var colorScheme: ColorScheme? { 17 | switch self { 18 | case .system: 19 | return nil 20 | case .light: 21 | return .light 22 | case .dark: 23 | return .dark 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/Views/General/CommonStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommonStatus.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/28/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CommonStatus: View { 12 | @AppStorage("Refreshed") private var refreshedTimestamp: Double? 13 | 14 | var body: some View { 15 | VStack { 16 | if let timestamp = refreshedTimestamp { 17 | RelativeRefreshedDate(timestamp: timestamp).font(.caption) 18 | } 19 | } 20 | .lineLimit(1) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | "project_id": "705247" 2 | "base_path": "." 3 | "base_url": "https://api.crowdin.com" 4 | "preserve_hierarchy": true 5 | 6 | files: [ 7 | { 8 | "source": "Den/InfoPlist.xcstrings", 9 | "translation": "Den/InfoPlist.xcstrings", 10 | "multilingual": true 11 | }, 12 | { 13 | "source": "Shared/Localizable.xcstrings", 14 | "translation": "Shared/Localizable.xcstrings", 15 | "multilingual": true 16 | }, 17 | { 18 | "source": "WidgetExtension/InfoPlist.xcstrings", 19 | "translation": "WidgetExtension/InfoPlist.xcstrings", 20 | "multilingual": true 21 | }, 22 | ] 23 | -------------------------------------------------------------------------------- /Den/Views/Feed/FeedTitleLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedTitleLabel.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/23/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import SDWebImageSwiftUI 12 | 13 | struct FeedTitleLabel: View { 14 | @ObservedObject var feed: Feed 15 | 16 | var body: some View { 17 | Label { 18 | feed.displayTitle.lineLimit(1) 19 | } icon: { 20 | Favicon(url: feed.feedData?.favicon) { 21 | FeedFaviconPlaceholder() 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Den/Views/General/CopyLinkButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CopyLinkButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/2/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CopyLinkButton: View { 12 | let url: URL 13 | 14 | var body: some View { 15 | Button { 16 | PasteboardUtility.copyURL(url: url) 17 | } label: { 18 | Label { 19 | Text("Copy Link", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "link") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /UITests/BookmarksUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksUITests.swift 3 | // UI Tests 4 | // 5 | // Created by Garrett Johnson on 11/1/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class BookmarksUITests: UITestCase { 12 | @MainActor 13 | func testNewTagSheet() throws { 14 | throw XCTSkip("Missing test") 15 | } 16 | 17 | @MainActor 18 | func testTagEmpty() throws { 19 | throw XCTSkip("Missing test") 20 | } 21 | 22 | @MainActor 23 | func testTagView() throws { 24 | throw XCTSkip("Missing test") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/FeedKitFeed+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedKitFeed+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/7/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension FeedKit.Feed: WebFeed { 14 | var webpage: URL? { 15 | switch self { 16 | case .atom(let atomFeed): 17 | return atomFeed.webpage 18 | case .rss(let rssFeed): 19 | return rssFeed.webpage 20 | case .json(let jsonFeed): 21 | return jsonFeed.webpage 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Den/UI/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/2/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | func windowScene( 14 | _ windowScene: UIWindowScene, 15 | performActionFor shortcutItem: UIApplicationShortcutItem, 16 | completionHandler: @escaping (Bool) -> Void 17 | ) { 18 | QuickActionManager.shared.shortcutItem = shortcutItem 19 | completionHandler(true) 20 | } 21 | } 22 | #endif 23 | -------------------------------------------------------------------------------- /Den/Views/General/GoToFeedNavLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GoToFeedNavLink.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/26/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GoToFeedNavLink: View { 12 | var feedObjectURL: URL 13 | 14 | var body: some View { 15 | NavigationLink(value: SubDetailPanel.feed(feedObjectURL)) { 16 | Label { 17 | Text("Go to Feed", comment: "Button label.") 18 | } icon: { 19 | Image(systemName: "dot.radiowaves.up.forward") 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Den/UI/Styles/CompactContentUnavailableLabelStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompactContentUnavailableLabelStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/14/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CompactContentUnavailableLabelStyle: LabelStyle { 12 | func makeBody(configuration: Configuration) -> some View { 13 | VStack(spacing: 8) { 14 | configuration.icon.foregroundStyle(.secondary) 15 | configuration.title.foregroundStyle(.secondary) 16 | } 17 | .imageScale(.large) 18 | .font(.callout) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /WidgetExtension/LatestItems/LatestItemsConfigurationIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestItemsConfigurationIntent.swift 3 | // Widget Extension 4 | // 5 | // Created by Garrett Johnson on 5/1/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | import AppIntents 11 | 12 | struct LatestItemsConfigurationIntent: WidgetConfigurationIntent { 13 | static let title: LocalizedStringResource = "Configuration" 14 | 15 | @Parameter(title: "Source") 16 | var source: SourceDetail? 17 | 18 | init(source: SourceDetail) { 19 | self.source = source 20 | } 21 | 22 | init() { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Den/UI/Modifiers/ImageBorderModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageBorderModifier.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/21/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ImageBorderModifier: ViewModifier { 12 | var cornerRadius: CGFloat = 8 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 17 | .overlay( 18 | RoundedRectangle(cornerRadius: cornerRadius) 19 | .strokeBorder(.separator.quinary, lineWidth: 1) 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Den/Views/Browser/BrowserNavControlGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowserNavControlGroup.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BrowserNavControlGroup: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | ControlGroup { 16 | GoBackButton(browserViewModel: browserViewModel) 17 | GoForwardButton(browserViewModel: browserViewModel) 18 | } label: { 19 | Text("Back/Forward", comment: "Control group label.") 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Den/Views/General/ImageDepression.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageDepression.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/21/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ImageDepression: View { 12 | var padding: CGFloat = 8 13 | var cornerRadius: CGFloat = 6 14 | 15 | let content: () -> Content 16 | 17 | var body: some View { 18 | content() 19 | .frame(maxWidth: .infinity, maxHeight: .infinity) 20 | .padding(padding) 21 | .background(.fill.tertiary) 22 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/ImportButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImportButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ImportButton: View { 12 | @SceneStorage("ShowingImporter") private var showingImporter: Bool = false 13 | 14 | var body: some View { 15 | Button { 16 | showingImporter = true 17 | } label: { 18 | Label { 19 | Text("Import OPML", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "arrow.down.doc") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Den/Extensions/Color+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Extensions.swift 3 | // Den 4 | // 5 | // Created by Anonymous S.I. on 04/03/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension Color { 12 | func hexString(environment: EnvironmentValues) -> String { 13 | let resolvedColor = self.resolve(in: environment) 14 | 15 | return String( 16 | format: "#%02lX%02lX%02lX%02lX", 17 | lroundf(resolvedColor.red * 255), 18 | lroundf(resolvedColor.green * 255), 19 | lroundf(resolvedColor.blue * 255), 20 | lroundf(resolvedColor.opacity * 255) 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Den/Views/General/OpenValidatorButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenValidatorButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/17/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct OpenValidatorButton: View { 12 | @Environment(\.openURL) private var openURL 13 | 14 | let url: URL 15 | 16 | var body: some View { 17 | Button { 18 | openURL(url) 19 | } label: { 20 | Label { 21 | Text("Open Validator", comment: "Button label.") 22 | } icon: { 23 | Image(systemName: "stethoscope") 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Den/UI/DenWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DenWebView.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 4/18/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import WebKit 11 | 12 | final class DenWebView: WKWebView { 13 | #if os(macOS) 14 | override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { 15 | for menuItem in menu.items { 16 | if menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" || 17 | menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" { 18 | menuItem.isHidden = true 19 | } 20 | } 21 | } 22 | #endif 23 | } 24 | -------------------------------------------------------------------------------- /Den/Utilities/MediaTypeUtility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaTypeUtility.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/30/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct MediaTypeUtility { 12 | static func mediaIsImage(mimeType: String?, medium: String?) -> Bool { 13 | if let mimeType = mimeType { 14 | if ImageMIMEType(rawValue: mimeType) != nil { 15 | return true 16 | } 17 | } 18 | 19 | if let medium = medium { 20 | if medium == "image" { 21 | return true 22 | } 23 | } 24 | 25 | return false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TestPlans/Default.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "9FA4D831-3DDF-4F31-8AFE-2155EFF59E01", 5 | "name" : "Default", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "commandLineArgumentEntries" : [ 14 | { 15 | "argument" : "-automatic-orientation" 16 | } 17 | ], 18 | "testTimeoutsEnabled" : true 19 | }, 20 | "testTargets" : [ 21 | { 22 | "target" : { 23 | "containerPath" : "container:Den.xcodeproj", 24 | "identifier" : "68E83BF82A6004590014DC64", 25 | "name" : "UI Tests" 26 | } 27 | } 28 | ], 29 | "version" : 1 30 | } 31 | -------------------------------------------------------------------------------- /Shared/Entities/TrendItem+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrendItem+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/23/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | @objc(TrendItem) 12 | final public class TrendItem: NSManagedObject { 13 | static func create( 14 | in managedObjectContext: NSManagedObjectContext, 15 | trend: Trend, 16 | item: Item 17 | ) -> TrendItem { 18 | let trendItem = self.init(context: managedObjectContext) 19 | trendItem.id = UUID() 20 | trendItem.trend = trend 21 | trendItem.item = item 22 | 23 | return trendItem 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/ExportButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExportButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct ExportButton: View { 13 | @SceneStorage("ShowingExporter") private var showingExporter: Bool = false 14 | 15 | var body: some View { 16 | Button { 17 | showingExporter = true 18 | } label: { 19 | Label { 20 | Text("Export OPML", comment: "Button label.") 21 | } icon: { 22 | Image(systemName: "arrow.up.doc") 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/Views/Navigation/Welcome.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Welcome.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/23/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Welcome: View { 12 | @FetchRequest(sortDescriptors: []) 13 | private var feeds: FetchedResults 14 | 15 | var body: some View { 16 | ContentUnavailable { 17 | Label { 18 | Text("Welcome", comment: "Welcome title.") 19 | } icon: { 20 | Image(systemName: "house") 21 | } 22 | } description: { 23 | Text("\(feeds.count) Feeds", comment: "Feed count.") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/PageZoomLevel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageZoomLevel.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/12/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum PageZoomLevel: Int, CaseIterable { 12 | case fiftyPercent = 50 13 | case seventyFivePercent = 75 14 | case eightyFivePercent = 85 15 | case oneHundredPercent = 100 16 | case oneHundredFifteenPercent = 115 17 | case oneHundredTwentyFivePercent = 125 18 | case oneHundredFiftyPercent = 150 19 | case oneHundredSeventyFivePercent = 175 20 | case twoHundredPercent = 200 21 | case twoHundredFiftyPercent = 250 22 | case threeHundredPercent = 300 23 | } 24 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/OrganizerButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrganizerButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/23/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct OrganizerButton: View { 12 | @Binding var detailPanel: DetailPanel? 13 | 14 | var body: some View { 15 | Button { 16 | detailPanel = .organizer 17 | } label: { 18 | Label { 19 | Text("Organizer", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "folder.badge.gearshape") 22 | } 23 | } 24 | .accessibilityIdentifier("OrganizerButton") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/Views/General/IconSelectorButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconSelectorButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IconSelectorButton: View { 12 | @Binding var showingIconSelector: Bool 13 | @Binding var symbol: String 14 | 15 | var body: some View { 16 | Button { 17 | showingIconSelector = true 18 | } label: { 19 | Label { 20 | Text("Change Icon", comment: "Button label.") 21 | } icon: { 22 | Image(systemName: symbol) 23 | } 24 | } 25 | .buttonStyle(.borderless) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Shared/Entities/Search+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Search+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/24/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | @objc(Search) 12 | final public class Search: NSManagedObject { 13 | var wrappedQuery: String { 14 | query ?? "" 15 | } 16 | 17 | static func create( 18 | in managedObjectContext: NSManagedObjectContext, 19 | query: String 20 | ) -> Search { 21 | let search = self.init(context: managedObjectContext) 22 | search.id = UUID() 23 | search.submitted = Date() 24 | search.query = query 25 | 26 | return search 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/SettingsButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/3/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SettingsButton: View { 12 | @SceneStorage("ShowingSettings") private var showingSettings: Bool = false 13 | 14 | var body: some View { 15 | Button { 16 | showingSettings = true 17 | } label: { 18 | Label { 19 | Text("Settings", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "gearshape") 22 | } 23 | } 24 | .accessibilityIdentifier("ShowSettings") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Shared/Entities/BlocklistStatus+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlocklistStatus+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | @objc(BlocklistStatus) 13 | final public class BlocklistStatus: NSManagedObject { 14 | static func create( 15 | in managedObjectContext: NSManagedObjectContext, 16 | blocklist: Blocklist 17 | ) -> BlocklistStatus { 18 | let blocklistStatus = self.init(context: managedObjectContext) 19 | blocklistStatus.id = UUID() 20 | blocklistStatus.blocklistId = blocklist.id 21 | 22 | return blocklistStatus 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Den/UI/Styles/ContentUnavailableLabelStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentUnavailableLabelStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/14/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ContentUnavailableLabelStyle: LabelStyle { 12 | func makeBody(configuration: Configuration) -> some View { 13 | VStack(spacing: 16) { 14 | configuration.icon.foregroundStyle(.tertiary) 15 | configuration.title.foregroundStyle(.secondary) 16 | } 17 | .imageScale(.large) 18 | #if os(macOS) 19 | .font(.largeTitle.weight(.bold)) 20 | #else 21 | .font(.title.weight(.bold)) 22 | #endif 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Den/Data/ImageMIMEType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageMIMEType.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/11/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum ImageMIMEType: String, CaseIterable { 12 | case icon = "image/x-icon" 13 | case msicon = "image/vnd.microsoft.icon" 14 | case bmp = "image/bmp" 15 | case png = "image/png" 16 | case apng = "image/apng" 17 | case jpeg = "image/jpeg" 18 | case gif = "image/gif" 19 | case webp = "image/webp" 20 | case svg = "image/svg+xml" 21 | case jpg = "image/jpg" // Allow incorrect JPEG MIME type usage 22 | case generic = "image/*" 23 | case all = "*/*;q=0.8" 24 | } 25 | -------------------------------------------------------------------------------- /Den/Views/Item/ItemMeta.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemMeta.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ItemMeta: View { 12 | @ObservedObject var item: Item 13 | 14 | var body: some View { 15 | HStack(spacing: 4) { 16 | if item.bookmarked { 17 | Image(systemName: "bookmark") 18 | .symbolVariant(.fill) 19 | .font(.caption2) 20 | .imageScale(.small) 21 | } 22 | 23 | if let published = item.published { 24 | PreviewDateline(date: published) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/Data/Transferable/TransferableFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransferrableFeed.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/29/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct TransferableFeed: Codable, Transferable { 13 | let objectURI: URL 14 | let feedURL: URL 15 | 16 | static var transferRepresentation: some TransferRepresentation { 17 | CodableRepresentation(contentType: .denFeed) 18 | ProxyRepresentation(exporting: \.feedURL) 19 | ProxyRepresentation(exporting: \.feedURL.absoluteString) 20 | } 21 | } 22 | 23 | extension UTType { 24 | static let denFeed = UTType(exportedAs: "net.devsci.den.transferable.feed") 25 | } 26 | -------------------------------------------------------------------------------- /Den/Data/Transferable/TransferableItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransferableItem.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct TransferableItem: Codable, Transferable { 13 | let objectURI: URL 14 | let linkURL: URL 15 | 16 | static var transferRepresentation: some TransferRepresentation { 17 | CodableRepresentation(contentType: .denItem) 18 | ProxyRepresentation(exporting: \.linkURL) 19 | ProxyRepresentation(exporting: \.linkURL.absoluteString) 20 | } 21 | } 22 | 23 | extension UTType { 24 | static let denItem = UTType(exportedAs: "net.devsci.den.transferable.item") 25 | } 26 | -------------------------------------------------------------------------------- /Den/Extensions/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/9/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Bundle { 12 | var name: String { 13 | return infoDictionary!["CFBundleName"] as? String ?? "NA" 14 | } 15 | 16 | var releaseVersionNumber: String { 17 | return infoDictionary?["CFBundleShortVersionString"] as? String ?? "NA" 18 | } 19 | 20 | var buildVersionNumber: String { 21 | return infoDictionary?["CFBundleVersion"] as? String ?? "0" 22 | } 23 | 24 | var copyright: String { 25 | return infoDictionary?["NSHumanReadableCopyright"] as? String ?? "NA" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Den/Data/WorkingTrend.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WorkingTrend.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/2/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import NaturalLanguage 11 | 12 | struct WorkingTrend: Identifiable { 13 | var id: String { "\(slug)-\(tag.rawValue)" } 14 | var slug: String 15 | var tag: NLTag 16 | var title: String 17 | var items: Set 18 | var feeds: Set 19 | } 20 | 21 | extension WorkingTrend: Equatable { 22 | static func == (lhs: WorkingTrend, rhs: WorkingTrend) -> Bool { 23 | return lhs.id == rhs.id 24 | } 25 | } 26 | 27 | extension WorkingTrend: Hashable { 28 | func hash(into hasher: inout Hasher) { 29 | hasher.combine(id) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/AtomFeedEntry+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AtomFeedEntry+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/8/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension AtomFeedEntry: WebFeedItem { 14 | var linkURL: URL? { 15 | guard 16 | let linkString = self.links?.first(where: { atomLink in 17 | atomLink.attributes?.rel == "alternate" || atomLink.attributes?.rel == nil 18 | })?.attributes?.href?.trimmingCharacters(in: .whitespacesAndNewlines), 19 | let linkURL = URL(string: linkString) 20 | else { 21 | return nil 22 | } 23 | 24 | return linkURL 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Shared/Enumerations/PreviewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/27/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum PreviewStyle: Int { 12 | case compressed = 0 13 | case expanded = 1 14 | 15 | init(from stringRepresentation: String) { 16 | if stringRepresentation == "expanded" { 17 | self = .expanded 18 | } else { 19 | self = .compressed 20 | } 21 | } 22 | 23 | /// Value used in OPML exports 24 | var stringRepresentation: String { 25 | if self == .expanded { 26 | return "expanded" 27 | } else { 28 | return "compressed" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Data/Transferable/TransferableBookmark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransferableBookmark.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct TransferableBookmark: Codable, Transferable { 13 | let objectURI: URL 14 | let linkURL: URL 15 | 16 | static var transferRepresentation: some TransferRepresentation { 17 | CodableRepresentation(contentType: .denBookmark) 18 | ProxyRepresentation(exporting: \.linkURL) 19 | ProxyRepresentation(exporting: \.linkURL.absoluteString) 20 | } 21 | } 22 | 23 | extension UTType { 24 | static let denBookmark = UTType(exportedAs: "net.devsci.den.transferable.bookmark") 25 | } 26 | -------------------------------------------------------------------------------- /Den/UI/Modifiers/DraggableFeedModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DraggableFeedModifier.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DraggableFeedModifier: ViewModifier { 12 | let feed: Feed 13 | 14 | func body(content: Content) -> some View { 15 | if let feedURL = feed.url { 16 | content 17 | .contentShape(Rectangle()) 18 | .draggable( 19 | TransferableFeed( 20 | objectURI: feed.objectID.uriRepresentation(), 21 | feedURL: feedURL 22 | ) 23 | ) 24 | } else { 25 | content 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/UI/Modifiers/DraggableItemModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DraggableItemModifier.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DraggableItemModifier: ViewModifier { 12 | let item: Item 13 | 14 | func body(content: Content) -> some View { 15 | if let linkURL = item.link { 16 | content 17 | .contentShape(Rectangle()) 18 | .draggable( 19 | TransferableItem( 20 | objectURI: item.objectID.uriRepresentation(), 21 | linkURL: linkURL 22 | ) 23 | ) 24 | } else { 25 | content 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/Views/Feed/NewFeedButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewFeedButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/3/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NewFeedButton: View { 12 | @SceneStorage("ShowingNewFeedSheet") private var showingNewFeedSheet: Bool = false 13 | 14 | var body: some View { 15 | Button { 16 | showingNewFeedSheet = true 17 | } label: { 18 | Label { 19 | Text("New Feed", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "note.text.badge.plus") 22 | } 23 | } 24 | .keyboardShortcut("k", modifiers: [.command]) 25 | .accessibilityIdentifier("NewFeed") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Den/Views/Page/NewPageButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewPageButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NewPageButton: View { 12 | @SceneStorage("ShowingNewPageSheet") private var showingNewPageSheet = false 13 | 14 | var body: some View { 15 | Button { 16 | showingNewPageSheet = true 17 | } label: { 18 | Label { 19 | Text("New Folder", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "folder.badge.plus") 22 | } 23 | } 24 | .keyboardShortcut("n", modifiers: [.command, .shift]) 25 | .accessibilityIdentifier("NewPage") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/BookmarksNavLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksNavLink.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | struct BookmarksNavLink: View { 13 | @Environment(\.managedObjectContext) private var viewContext 14 | 15 | var body: some View { 16 | Label { 17 | Text("Bookmarks", comment: "Button label.") 18 | } icon: { 19 | Image(systemName: "book") 20 | } 21 | .tag(DetailPanel.bookmarks) 22 | .onDrop( 23 | of: [.denItem], 24 | delegate: BookmarksNavDropDelegate(context: viewContext) 25 | ) 26 | .accessibilityIdentifier("BookmarksNavLink") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/RSSFeed+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RSSFeed+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/18/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension RSSFeed: WebFeed { 14 | var webpage: URL? { 15 | if 16 | let urlString = self.link?.trimmingCharacters(in: .whitespacesAndNewlines), 17 | let link = URL(string: urlString) 18 | { 19 | return link 20 | } 21 | 22 | return nil 23 | } 24 | 25 | var itemsSortedByDateAndTitle: [RSSFeedItem]? { 26 | items?.sorted(using: [ 27 | SortDescriptor(\.pubDate, order: .reverse), 28 | SortDescriptor(\.title) 29 | ]) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/Preview/PreviewDateline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewDateline.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/16/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PreviewDateline: View { 12 | let date: Date 13 | 14 | let dateFormatter: DateFormatter = { 15 | let formatter = DateFormatter() 16 | formatter.timeStyle = .short 17 | formatter.dateStyle = .medium 18 | formatter.locale = .current 19 | formatter.doesRelativeDateFormatting = true 20 | 21 | return formatter 22 | }() 23 | 24 | var body: some View { 25 | TimelineView(.everyMinute) { _ in 26 | Text(dateFormatter.string(from: date)).font(.subheadline.italic()).lineLimit(1) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Den/Views/Feed/FeedNavLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedNavLink.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedNavLink: View { 12 | @ObservedObject var feed: Feed 13 | 14 | var body: some View { 15 | NavigationLink(value: SubDetailPanel.feed(feed.objectID.uriRepresentation())) { 16 | HStack { 17 | FeedTitleLabel(feed: feed) 18 | .modifier(DraggableFeedModifier(feed: feed)) 19 | .help(Text("Drag to move feed to another page.", comment: "Draggable help text.")) 20 | Spacer() 21 | ButtonChevron() 22 | } 23 | } 24 | .accessibilityIdentifier("FeedNavLink") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/UI/Modifiers/DraggableBookmarkModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DraggableBookmarkModifier.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DraggableBookmarkModifier: ViewModifier { 12 | let bookmark: Bookmark 13 | 14 | func body(content: Content) -> some View { 15 | if let linkURL = bookmark.link { 16 | content 17 | .contentShape(Rectangle()) 18 | .draggable( 19 | TransferableBookmark( 20 | objectURI: bookmark.objectID.uriRepresentation(), 21 | linkURL: linkURL 22 | ) 23 | ) 24 | } else { 25 | content 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/UI/NetworkMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkMonitor.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/11/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Network 11 | 12 | @MainActor 13 | final class NetworkMonitor: ObservableObject { 14 | @Published private(set) var isConnected: Bool = false 15 | 16 | private let networkMonitor = NWPathMonitor() 17 | private let workerQueue = DispatchQueue(label: "Monitor") 18 | 19 | init() { 20 | networkMonitor.pathUpdateHandler = { path in 21 | Task { 22 | await MainActor.run { 23 | self.isConnected = path.status == .satisfied 24 | } 25 | } 26 | } 27 | networkMonitor.start(queue: workerQueue) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Den/Data/OPMLFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OPMLFile.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/18/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct OPMLFile: FileDocument { 13 | static let readableContentTypes = [UTType(importedAs: "public.opml"), .xml] 14 | 15 | var data: Data = Data() 16 | 17 | init(initialData: Data) { 18 | data = initialData 19 | } 20 | 21 | init(configuration: ReadConfiguration) throws { 22 | if let fileData = configuration.file.regularFileContents { 23 | data = fileData 24 | } 25 | } 26 | 27 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 28 | return FileWrapper(regularFileWithContents: data) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/JSONFeed+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONFeed+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/18/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension JSONFeed: WebFeed { 14 | var webpage: URL? { 15 | if 16 | let urlString = self.homePageURL?.trimmingCharacters(in: .whitespacesAndNewlines), 17 | let linkURL = URL(string: urlString) 18 | { 19 | return linkURL 20 | } 21 | 22 | return nil 23 | } 24 | 25 | var itemsSortedByDateAndTitle: [JSONFeedItem]? { 26 | items?.sorted(using: [ 27 | SortDescriptor(\.datePublished, order: .reverse), 28 | SortDescriptor(\.title) 29 | ]) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /WebExtension/Resources/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { 3 | "message": "Den for RSS", 4 | "description": "The display name for the extension." 5 | }, 6 | "extension_description": { 7 | "message": "Shows available syndication feeds.", 8 | "description": "Description of what the extension does." 9 | }, 10 | "no_results": { 11 | "message": "No Feeds", 12 | "description": "Shown when no feeds were found." 13 | }, 14 | "copied": { 15 | "message": "Copied “$URL$”", 16 | "description": "Shown when the user copies a feed URL to the pasteboard.", 17 | "placeholders": { 18 | "url" : { 19 | "content" : "$1", 20 | "example" : "https://den.io/feed.rss" 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetAssets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x50", 9 | "green" : "0x7F", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "display-gamut" : "sRGB", 14 | "idiom" : "universal" 15 | }, 16 | { 17 | "color" : { 18 | "color-space" : "display-p3", 19 | "components" : { 20 | "alpha" : "1.000", 21 | "blue" : "0x5B", 22 | "green" : "0x86", 23 | "red" : "0xEE" 24 | } 25 | }, 26 | "display-gamut" : "display-P3", 27 | "idiom" : "universal" 28 | } 29 | ], 30 | "info" : { 31 | "author" : "xcode", 32 | "version" : 1 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Den/UI/NavigationStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationStore.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/8/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | final class NavigationStore: ObservableObject { 12 | @Published var path = NavigationPath() 13 | 14 | private let decoder = JSONDecoder() 15 | private let encoder = JSONEncoder() 16 | 17 | func encoded() -> Data? { 18 | try? path.codable.map(encoder.encode) 19 | } 20 | 21 | func restore(from data: Data) { 22 | do { 23 | let codable = try decoder.decode( 24 | NavigationPath.CodableRepresentation.self, from: data 25 | ) 26 | path = NavigationPath(codable) 27 | } catch { 28 | path = NavigationPath() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/Page/DeletePageButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeletePageButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/18/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DeletePageButton: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | 14 | @ObservedObject var page: Page 15 | 16 | var body: some View { 17 | Button(role: .destructive) { 18 | page.feedsArray.compactMap { $0.feedData }.forEach { viewContext.delete($0) } 19 | viewContext.delete(page) 20 | 21 | do { 22 | try viewContext.save() 23 | } catch { 24 | CrashUtility.handleCriticalError(error as NSError) 25 | } 26 | } label: { 27 | DeleteLabel() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /UITests/SidebarUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarUITests.swift 3 | // UITests 4 | // 5 | // Created by Garrett Johnson on 7/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class SidebarUITests: UITestCase { 12 | @MainActor 13 | func testSidebarGetStarted() throws { 14 | let app = launchApp(inMemory: true) 15 | 16 | attachScreenshot(of: app.windows.firstMatch, named: "sidebar-get-started") 17 | } 18 | 19 | @MainActor 20 | func testSidebarAppMenu() throws { 21 | let app = launchApp(inMemory: false) 22 | 23 | #if os(macOS) 24 | app.popUpButtons["SidebarMenu"].tap() 25 | #else 26 | app.buttons["SidebarMenu"].tap() 27 | #endif 28 | 29 | attachScreenshot(of: app.windows.firstMatch, named: "sidebar-app-menu") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/Bookmark/BookmarksSpreadLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksSpreadLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/19/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BookmarksSpreadLayout: View { 12 | let bookmarks: FetchedResults 13 | 14 | var body: some View { 15 | GeometryReader { geometry in 16 | ScrollView { 17 | BoardView(width: geometry.size.width, list: Array(bookmarks)) { bookmark in 18 | if bookmark.wrappedLargePreview { 19 | BookmarkPreviewExpanded(bookmark: bookmark) 20 | } else { 21 | BookmarkPreviewCompressed(bookmark: bookmark) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /UITests/UIDeviceOrientation+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIDeviceOrientation+Extensions.swift 3 | // UITests 4 | // 5 | // Created by Garrett Johnson on 7/18/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | #if os(iOS) 12 | extension UIDeviceOrientation { 13 | var name: String { 14 | switch self { 15 | case .unknown: 16 | "Unknown" 17 | case .portrait: 18 | "Portrait" 19 | case .portraitUpsideDown: 20 | "PortraitUpsideDown" 21 | case .landscapeLeft: 22 | "LandscapeLeft" 23 | case .landscapeRight: 24 | "LandscapeRight" 25 | case .faceUp: 26 | "FaceUp" 27 | case .faceDown: 28 | "FaceDown" 29 | @unknown default: 30 | "Default" 31 | } 32 | } 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Den/UI/Modifiers/SafeAreaModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafeAreaModifier.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 4/4/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SafeAreaModifier: ViewModifier { 12 | let geometry: GeometryProxy 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .safeAreaInset(edge: .leading, spacing: 0) { 17 | if geometry.safeAreaInsets.leading > 0 { 18 | ZStack {}.frame(width: geometry.safeAreaInsets.leading) 19 | } 20 | } 21 | .safeAreaInset(edge: .trailing, spacing: 0) { 22 | if geometry.safeAreaInsets.trailing > 0 { 23 | ZStack {}.frame(width: geometry.safeAreaInsets.trailing) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Den/UI/Modifiers/PreviewImageStateModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewImageStateModifier.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/28/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PreviewImageStateModifier: ViewModifier { 12 | let isRead: Bool 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .grayscale(grayscale) 17 | .overlay(overlay) 18 | } 19 | 20 | private var grayscale: CGFloat { 21 | if isRead { 22 | return 0.2 23 | } else { 24 | return 0 25 | } 26 | } 27 | 28 | private var overlay: some ShapeStyle { 29 | if isRead { 30 | return .background.opacity(0.4) 31 | } else { 32 | return .background.opacity(0) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Den/Views/Browser/ReaderViewMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReaderViewMenu.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/20/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ReaderViewMenu: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | Menu { 16 | ToggleReaderButton(browserViewModel: browserViewModel) 17 | ReaderZoom(browserViewModel: browserViewModel) 18 | } label: { 19 | Label { 20 | Text("View", comment: "Button label.") 21 | } icon: { 22 | Image(systemName: "doc.plaintext.fill") 23 | } 24 | } primaryAction: { 25 | browserViewModel.toggleReader() 26 | } 27 | .accessibilityIdentifier("ReaderViewMenu") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Shared/Extensions/FileManager+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 4/22/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | extension FileManager { 13 | var appSupportDirectory: URL? { 14 | do { 15 | return try self 16 | .url( 17 | for: .applicationSupportDirectory, 18 | in: .userDomainMask, 19 | appropriateFor: nil, 20 | create: true 21 | ) 22 | .appendingPathComponent("Den") 23 | .standardizedFileURL 24 | } catch let error as NSError { 25 | Logger.main.error("Could not find app support directory: \(error)") 26 | return nil 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.aps-environment 8 | development 9 | com.apple.developer.icloud-container-identifiers 10 | 11 | iCloud.net.devsci.den 12 | 13 | com.apple.developer.icloud-services 14 | 15 | CloudKit 16 | 17 | com.apple.security.app-sandbox 18 | 19 | com.apple.security.application-groups 20 | 21 | group.net.devsci.den 22 | 23 | com.apple.security.network.client 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Den/UI/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/2/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | final class AppDelegate: NSObject, UIApplicationDelegate { 13 | func application( 14 | _ application: UIApplication, 15 | configurationForConnecting connectingSceneSession: UISceneSession, 16 | options: UIScene.ConnectionOptions 17 | ) -> UISceneConfiguration { 18 | QuickActionManager.shared.shortcutItem = options.shortcutItem 19 | 20 | let sceneConfiguration = UISceneConfiguration( 21 | name: "Default", 22 | sessionRole: connectingSceneSession.role 23 | ) 24 | sceneConfiguration.delegateClass = SceneDelegate.self 25 | 26 | return sceneConfiguration 27 | } 28 | } 29 | #endif 30 | -------------------------------------------------------------------------------- /Den/Views/Feed/DeleteFeedButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteFeedButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/18/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DeleteFeedButton: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | 14 | @ObservedObject var feed: Feed 15 | 16 | var body: some View { 17 | Button(role: .destructive) { 18 | if let feedData = feed.feedData { viewContext.delete(feedData) } 19 | viewContext.delete(feed) 20 | 21 | do { 22 | try viewContext.save() 23 | } catch { 24 | CrashUtility.handleCriticalError(error as NSError) 25 | } 26 | } label: { 27 | DeleteLabel() 28 | } 29 | .accessibilityIdentifier("DeleteFeed") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/General/SystemBrowserButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemBrowserButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/1/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SystemBrowserButton: View { 12 | @Environment(\.openURL) private var openURL 13 | 14 | let url: URL 15 | var callback: (() -> Void)? 16 | 17 | var body: some View { 18 | Button { 19 | openURL(url) 20 | callback?() 21 | } label: { 22 | Label { 23 | Text("Open in Browser", comment: "Button label.") 24 | } icon: { 25 | Image(systemName: "safari") 26 | } 27 | } 28 | .help(Text("Open in default web browser", comment: "Button help text.")) 29 | .accessibilityIdentifier("OpenInWebBrowser") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/Browser/DownloadsButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadsButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/22/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DownloadsButton: View { 12 | @State private var showingPopover = false 13 | 14 | var body: some View { 15 | Button { 16 | showingPopover = true 17 | } label: { 18 | Label { 19 | Text("Downloads", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "arrow.down.circle") 22 | } 23 | } 24 | .popover(isPresented: $showingPopover, arrowEdge: .top) { 25 | DownloadsPopover() 26 | } 27 | .help(Text("Show downloads", comment: "Button help text.")) 28 | .accessibilityIdentifier("Downloads") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Den/Views/Browser/GoBackButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GoBackButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GoBackButton: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | Button { 16 | browserViewModel.goBack() 17 | } label: { 18 | Label { 19 | Text("Go Back", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "chevron.backward") 22 | } 23 | } 24 | .disabled(!browserViewModel.canGoBack) 25 | .keyboardShortcut("[", modifiers: .command) 26 | .help(Text("Show previous page", comment: "Button help text.")) 27 | .accessibilityIdentifier("BrowserGoBack") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Den/Views/Browser/GoForwardButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GoForwardButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GoForwardButton: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | Button { 16 | browserViewModel.goForward() 17 | } label: { 18 | Label { 19 | Text("Go Forward", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: "chevron.forward") 22 | } 23 | } 24 | .disabled(!browserViewModel.canGoForward) 25 | .keyboardShortcut("]", modifiers: .command) 26 | .help(Text("Show next page", comment: "Button help text.")) 27 | .accessibilityIdentifier("BrowserGoForward") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /WebExtension/Resources/background.js: -------------------------------------------------------------------------------- 1 | // 2 | // background.js 3 | // WebExtension 4 | // 5 | // Created by Garrett Johnson on 10/24/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | function updateBadge(tabId, count) { 10 | if (count > 0) { 11 | browser.browserAction.setBadgeText({tabId: tabId, text: String(count)}); 12 | browser.browserAction.enable(tabId); 13 | } else { 14 | browser.browserAction.setBadgeText({tabId: tabId, text: null}); 15 | browser.browserAction.disable(tabId); 16 | } 17 | } 18 | 19 | browser.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) { 20 | browser 21 | .tabs 22 | .sendMessage(tabId, {"subject": "scan"}) 23 | .then(function(results) { 24 | if (results !== undefined) { 25 | updateBadge(tabId, results.data.length) 26 | } 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /Den/Views/Browser/ShowInFinderButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowInFinderButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 4/15/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ShowInFinderButton: View { 12 | let url: URL 13 | 14 | var body: some View { 15 | Button { 16 | #if os(macOS) 17 | if url.hasDirectoryPath { 18 | NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) 19 | } else { 20 | NSWorkspace.shared.activateFileViewerSelecting([url]) 21 | } 22 | #endif 23 | } label: { 24 | Label { 25 | Text("Show in Finder", comment: "Button label.") 26 | } icon: { 27 | Image(systemName: "magnifyingglass.circle.fill") 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/Inbox/InboxLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/24/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InboxLayout: View { 12 | let items: [Item] 13 | 14 | var body: some View { 15 | GeometryReader { geometry in 16 | ScrollView(.vertical) { 17 | BoardView(width: geometry.size.width, list: items) { item in 18 | if let feed = item.feedData?.feed { 19 | if feed.wrappedPreviewStyle == .expanded { 20 | FeedItemExpanded(item: item, feed: feed) 21 | } else { 22 | FeedItemCompressed(item: item, feed: feed) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Den/Views/Browser/ToggleReaderButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleReaderButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/20/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ToggleReaderButton: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | Button { 16 | browserViewModel.toggleReader() 17 | } label: { 18 | Label { 19 | if browserViewModel.showingReader { 20 | Text("Hide Reader", comment: "Button label.") 21 | } else { 22 | Text("Show Reader", comment: "Button label.") 23 | } 24 | } icon: { 25 | Image(systemName: "doc.plaintext") 26 | } 27 | } 28 | .keyboardShortcut("r", modifiers: [.command, .shift]) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Den/UI/Styles/ThinLinearProgressViewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThinLinearProgressViewStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/3/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ThinLinearProgressViewStyle: ProgressViewStyle { 12 | func makeBody(configuration: Configuration) -> some View { 13 | if let fractionCompleted = configuration.fractionCompleted { 14 | GeometryReader { geometry in 15 | ZStack(alignment: .leading) { 16 | Rectangle().fill(.fill.secondary) 17 | Rectangle() 18 | .fill(.tint) 19 | .frame(width: fractionCompleted * geometry.size.width) 20 | .animation(.linear, value: fractionCompleted) 21 | } 22 | .frame(height: 2) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Den/Views/Browser/ToggleJavaScriptButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleJavaScriptButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/21/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ToggleJavaScriptButton: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | Button { 16 | Task { 17 | await browserViewModel.toggleJavaScript() 18 | } 19 | } label: { 20 | Label { 21 | if browserViewModel.allowJavaScript { 22 | Text("Disable JavaScript", comment: "Button label.") 23 | } else { 24 | Text("Enable JavaScript", comment: "Button label.") 25 | } 26 | } icon: { 27 | Image(systemName: "curlybraces") 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Extensions/Set+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Extend UUID Set to be RawRepresentable for compatibility with SceneStorage 12 | extension Set: @retroactive RawRepresentable where Element == UUID { 13 | public init?(rawValue: String) { 14 | guard 15 | let data = rawValue.data(using: .utf8), 16 | let result = try? JSONDecoder().decode(Set.self, from: data) else { 17 | return nil 18 | } 19 | self = result 20 | } 21 | 22 | public var rawValue: String { 23 | guard 24 | let data = try? JSONEncoder().encode(self), 25 | let jsonString = String(data: data, encoding: .utf8) 26 | else { return "[]" } 27 | 28 | return jsonString 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Den/Extensions/CaseIterable+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaseIterable+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/12/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension CaseIterable where Self: Equatable, AllCases: BidirectionalCollection { 12 | func previous() -> Self? { 13 | let all = Self.allCases 14 | guard let idx = all.firstIndex(of: self) else { return nil } 15 | 16 | let previous = all.index(before: idx) 17 | guard all.indices.contains(previous) else { return nil } 18 | 19 | return all[previous] 20 | } 21 | 22 | func next() -> Self? { 23 | let all = Self.allCases 24 | guard let idx = all.firstIndex(of: self) else { return nil } 25 | 26 | let next = all.index(after: idx) 27 | guard all.indices.contains(next) else { return nil } 28 | 29 | return all[next] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Den/Views/Feed/FeedEmpty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedEmpty.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/26/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedEmpty: View { 12 | var largeDisplay: Bool = false 13 | 14 | var body: some View { 15 | if largeDisplay { 16 | ContentUnavailable { 17 | Label { 18 | Text("Feed Empty", comment: "Content unavailable title.") 19 | } icon: { 20 | Image(systemName: "questionmark.folder") 21 | } 22 | } 23 | } else { 24 | CompactContentUnavailable { 25 | Label { 26 | Text("Feed Empty", comment: "Content unavailable title.") 27 | } icon: { 28 | Image(systemName: "questionmark.folder") 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Den/Views/General/AppErrorSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppErrorSheet.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/3/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AppErrorSheet: View { 12 | @Binding var message: String? 13 | 14 | var body: some View { 15 | ContentUnavailable { 16 | Label { 17 | Text("Application Error", comment: "Crash view title.") 18 | } icon: { 19 | Image(systemName: "xmark.octagon") 20 | } 21 | } description: { 22 | VStack { 23 | Text( 24 | "A critical error occurred. Restart to try again.", 25 | comment: "Crash view guidance." 26 | ) 27 | 28 | if let message { 29 | Text(message) 30 | } 31 | } 32 | } 33 | .padding() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Den/Views/General/SafariViewButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InAppSafariButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/22/24. 6 | // Copyright © 2024 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | struct SafariViewButton: View { 13 | @Environment(\.openURLInSafariView) private var openURLInSafariView 14 | 15 | let url: URL 16 | var entersReaderIfAvailable: Bool? 17 | var callback: (() -> Void)? 18 | 19 | var body: some View { 20 | Button { 21 | openURLInSafariView(url, entersReaderIfAvailable) 22 | callback?() 23 | } label: { 24 | Label { 25 | Text("Open in Safari View", comment: "Button label.") 26 | } icon: { 27 | Image(systemName: "safari") 28 | } 29 | } 30 | .help(Text("Open in Safari view", comment: "Button help text.")) 31 | .accessibilityIdentifier("OpenInSafariView") 32 | } 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Den/Views/General/PagePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PagePicker.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/30/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PagePicker: View { 12 | @Binding var selection: Page? 13 | 14 | var labelText: Text = Text("Folder", comment: "Picker label.") 15 | 16 | @FetchRequest(sortDescriptors: [ 17 | SortDescriptor(\.userOrder, order: .forward), 18 | SortDescriptor(\.name, order: .forward) 19 | ]) 20 | private var pages: FetchedResults 21 | 22 | var body: some View { 23 | Picker(selection: $selection) { 24 | ForEach(pages) { page in 25 | page.displayName.tag(page as Page?) 26 | } 27 | } label: { 28 | Label { 29 | labelText 30 | } icon: { 31 | Image(systemName: "folder") 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Den/Views/Settings/AppearanceSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceSection.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/2/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | 12 | struct AppearanceSection: View { 13 | @AppStorage("AccentColor") private var accentColor: AccentColorOption = .coral 14 | @AppStorage("UserColorScheme") private var userColorScheme: UserColorScheme = .system 15 | 16 | var body: some View { 17 | Section { 18 | UserColorSchemePicker(userColorScheme: $userColorScheme) 19 | AccentColorSelector(selection: $accentColor) 20 | } header: { 21 | Text("Appearance", comment: "Settings section header.") 22 | } 23 | .onChange(of: accentColor) { 24 | UserDefaults.group.set(accentColor.rawValue, forKey: "AccentColor") 25 | WidgetCenter.shared.reloadAllTimelines() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Den/Views/General/NoFeeds.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NoFeeds.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/31/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NoFeeds: View { 12 | var symbol = "folder" 13 | 14 | var body: some View { 15 | ContentUnavailable { 16 | Label { 17 | Text("No Feeds", comment: "Content unavailable title.") 18 | } icon: { 19 | Image(systemName: symbol) 20 | } 21 | } description: { 22 | Text( 23 | """ 24 | Open a syndication link or drag a feed web address onto a folder in the sidebar. \ 25 | Use the Safari extension to discover feeds on websites. 26 | """, 27 | comment: "No feeds guidance." 28 | ) 29 | .imageScale(.small) 30 | } actions: { 31 | NewFeedButton() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Den/Views/Bookmark/BookmarksLayoutPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksLayoutPicker.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/20/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BookmarksLayoutPicker: View { 12 | @Binding var layout: BookmarksLayout 13 | 14 | var body: some View { 15 | Picker(selection: $layout) { 16 | Label { 17 | Text("Previews", comment: "Bookmarks layout option.") 18 | } icon: { 19 | Image(systemName: "square.grid.2x2") 20 | } 21 | .tag(BookmarksLayout.previews) 22 | 23 | Label { 24 | Text("List", comment: "Bookmarks layout option.") 25 | } icon: { 26 | Image(systemName: "list.dash") 27 | } 28 | .tag(BookmarksLayout.list) 29 | } label: { 30 | Text("View", comment: "Picker label.") 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/RefreshButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/3/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RefreshButton: View { 12 | @EnvironmentObject private var networkMonitor: NetworkMonitor 13 | @EnvironmentObject private var refreshManager: RefreshManager 14 | 15 | var body: some View { 16 | Button { 17 | Task { 18 | await refreshManager.refresh() 19 | } 20 | } label: { 21 | Label { 22 | Text("Refresh", comment: "Button label.") 23 | } icon: { 24 | Image(systemName: "arrow.clockwise") 25 | } 26 | } 27 | .keyboardShortcut("r", modifiers: [.command]) 28 | .help(Text("Refresh feeds", comment: "Button help text.")) 29 | .accessibilityIdentifier("Refresh") 30 | .disabled(refreshManager.refreshing || !networkMonitor.isConnected) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /UITests/XCUIElement+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIElement+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCUIElement { 12 | func forceTap() { 13 | #if os(macOS) 14 | self.tap() 15 | #else 16 | if self.isHittable { 17 | self.tap() 18 | } else { 19 | let coordinate: XCUICoordinate = self.coordinate( 20 | withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0) 21 | ) 22 | coordinate.tap() 23 | } 24 | #endif 25 | } 26 | 27 | func waitUntilAvailable(_ test: UITestCase) -> XCUIElement { 28 | test.expectation( 29 | for: NSPredicate(format: "exists == 1 AND hittable == 1"), 30 | evaluatedWith: self, 31 | handler: nil 32 | ) 33 | test.waitForExpectations(timeout: 10, handler: nil) 34 | 35 | return self 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/InboxNavLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxNavLink.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/30/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InboxNavLink: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | @Environment(\.showUnreadCounts) private var showUnreadCounts 14 | 15 | let items: FetchedResults 16 | 17 | var body: some View { 18 | Label { 19 | Text("Inbox", comment: "Button label.") 20 | } icon: { 21 | Image(systemName: items.count > 0 ? "tray.full" : "tray") 22 | } 23 | .badge(showUnreadCounts ? items.unread.count : 0) 24 | .tag(DetailPanel.inbox) 25 | .accessibilityIdentifier("InboxNavLink") 26 | .contextMenu { 27 | MarkAllReadUnreadButton(allRead: items.unread.isEmpty) { 28 | HistoryUtility.toggleRead(items: items, context: viewContext) 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/JSONFeedItem+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONFeedItem+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/18/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension JSONFeedItem: WebFeedItem { 14 | /** 15 | Returns a webpage URL for the item; value is preferred, with fallback to . 16 | For example, items from https://devblogs.microsoft.com/feed/landingpage/ do not include a link. 17 | */ 18 | var linkURL: URL? { 19 | if 20 | let itemLinkString = self.url?.trimmingCharacters(in: .whitespacesAndNewlines), 21 | let linkURL = URL(string: itemLinkString) 22 | { 23 | return linkURL 24 | } else if 25 | let itemGUIDString = self.id?.trimmingCharacters(in: .whitespacesAndNewlines), 26 | let linkURL = URL(string: itemGUIDString) 27 | { 28 | return linkURL 29 | } 30 | 31 | return nil 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Den/UI/BrowserDownload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowserDownload.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/26/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | import WebKit 12 | 13 | @Observable final class BrowserDownload: Hashable, Identifiable { 14 | var wkDownload: WKDownload 15 | var fileURL: URL 16 | var isFinished = false 17 | var error: Error? 18 | 19 | init(wkDownload: WKDownload, fileURL: URL) { 20 | self.wkDownload = wkDownload 21 | self.fileURL = fileURL 22 | } 23 | 24 | func hash(into hasher: inout Hasher) { 25 | hasher.combine(wkDownload) 26 | } 27 | 28 | static func == (lhs: BrowserDownload, rhs: BrowserDownload) -> Bool { 29 | lhs.wkDownload == rhs.wkDownload 30 | } 31 | } 32 | 33 | extension Collection where Element == BrowserDownload { 34 | func forWKDownload(_ download: WKDownload) -> BrowserDownload? { 35 | self.first(where: { $0.wkDownload == download }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/RSSFeedItem+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RSSFeedItem+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/8/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension RSSFeedItem: WebFeedItem { 14 | /** 15 | Returns a webpage URL for the item; value is preferred, with fallback to . 16 | For example, items from https://devblogs.microsoft.com/feed/landingpage/ do not include a link. 17 | */ 18 | var linkURL: URL? { 19 | if 20 | let itemLinkString = self.link?.trimmingCharacters(in: .whitespacesAndNewlines), 21 | let linkURL = URL(string: itemLinkString) 22 | { 23 | return linkURL 24 | } else if 25 | let itemGUIDString = self.guid?.value?.trimmingCharacters(in: .whitespacesAndNewlines), 26 | let linkURL = URL(string: itemGUIDString) 27 | { 28 | return linkURL 29 | } 30 | 31 | return nil 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Den/UI/Columnizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Columnizer.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/29/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Columnizer { 12 | static let idealColumnWidth: CGFloat = 320 13 | 14 | static func columnize(columnCount: Int, list: [T]) -> [(Int, [T])] { 15 | var columnData: [(Int, [T])] = [] 16 | 17 | let columnCount = max(1, columnCount) 18 | 19 | // Setup empty columns 20 | for columnIndex in 0...columnCount - 1 { 21 | columnData.append((columnIndex, [])) 22 | } 23 | 24 | // Populate the data array 25 | var currentColumn = 0 26 | for object in list { 27 | columnData[currentColumn].1.append(object) 28 | 29 | if currentColumn == (columnCount - 1) { 30 | currentColumn = 0 31 | } else { 32 | currentColumn += 1 33 | } 34 | } 35 | 36 | return columnData 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Den/Views/General/BoardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BoardView.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/28/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BoardView: View where T: Hashable { 12 | let width: CGFloat 13 | let list: [T] 14 | 15 | @ViewBuilder let content: (T) -> Content 16 | 17 | @ScaledMetric private var idealColumnWidth = Columnizer.idealColumnWidth 18 | 19 | var body: some View { 20 | HStack(alignment: .top) { 21 | ForEach( 22 | Columnizer.columnize( 23 | columnCount: Int(width / idealColumnWidth), 24 | list: list 25 | ), 26 | id: \.0 27 | ) { _, columnObjects in 28 | LazyVStack { 29 | ForEach(columnObjects) { object in 30 | content(object) 31 | } 32 | } 33 | } 34 | } 35 | .padding() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Views/Trending/Trending.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Trending.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/1/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Trending: View { 12 | var body: some View { 13 | WithTrends { trends in 14 | if trends.isEmpty { 15 | ContentUnavailable { 16 | Label { 17 | Text("No Trends", comment: "Content unavailable title.") 18 | } icon: { 19 | Image(systemName: "chart.line.downtrend.xyaxis") 20 | } 21 | } description: { 22 | Text( 23 | "No common subjects were found in titles.", 24 | comment: "Trending empty message." 25 | ) 26 | } 27 | } else { 28 | TrendingLayout(trends: trends) 29 | } 30 | } 31 | .navigationTitle(Text("Trending", comment: "Navigation title.")) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Den/Views/Browser/ToggleBlocklistsButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleBlocklistsButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ToggleBlocklistsButton: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | Button { 16 | Task { 17 | await browserViewModel.toggleBlocklists() 18 | } 19 | } label: { 20 | if browserViewModel.useBlocklists { 21 | Label { 22 | Text("Turn Off Blocklists", comment: "Button label.") 23 | } icon: { 24 | Image(systemName: "shield.slash") 25 | } 26 | } else { 27 | Label { 28 | Text("Turn On Blocklists", comment: "Button label.") 29 | } icon: { 30 | Image(systemName: "checkmark.shield") 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Den/Views/General/WithTrends.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WithTrends.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/8/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | struct WithTrends: View { 13 | @ViewBuilder let content: (FetchedResults) -> Content 14 | 15 | @FetchRequest(sortDescriptors: []) 16 | private var trends: FetchedResults 17 | 18 | var body: some View { 19 | content(trends) 20 | } 21 | 22 | init( 23 | readFilter: Bool? = nil, 24 | @ViewBuilder content: @escaping (FetchedResults) -> Content 25 | ) { 26 | self.content = content 27 | 28 | let request = Trend.fetchRequest() 29 | request.sortDescriptors = [NSSortDescriptor(keyPath: \Trend.title, ascending: true)] 30 | 31 | if readFilter != nil { 32 | request.predicate = NSPredicate(format: "read = %@", NSNumber(value: readFilter!)) 33 | } 34 | 35 | _trends = FetchRequest(fetchRequest: request) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/TrendingNavLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrendingNavLink.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/30/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TrendingNavLink: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | @Environment(\.showUnreadCounts) private var showUnreadCounts 14 | 15 | var body: some View { 16 | WithTrends { trends in 17 | Label { 18 | Text("Trending", comment: "Button label.") 19 | } icon: { 20 | Image(systemName: "chart.line.uptrend.xyaxis") 21 | } 22 | .badge(showUnreadCounts ? trends.containingUnread.count : 0) 23 | .tag(DetailPanel.trending) 24 | .accessibilityIdentifier("TrendingNavLink") 25 | .contextMenu { 26 | MarkAllReadUnreadButton(allRead: trends.containingUnread.isEmpty) { 27 | HistoryUtility.toggleRead(items: trends.items, context: viewContext) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Den/Views/Bookmark/UnbookmarkButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnbookmarkButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/17/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct UnbookmarkButton: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | 14 | @ObservedObject var bookmark: Bookmark 15 | 16 | var callback: (() -> Void)? 17 | 18 | var body: some View { 19 | Button(role: .destructive) { 20 | viewContext.delete(bookmark) 21 | do { 22 | try viewContext.save() 23 | callback?() 24 | } catch { 25 | CrashUtility.handleCriticalError(error as NSError) 26 | } 27 | } label: { 28 | Label { 29 | Text("Unbookmark", comment: "Button label.") 30 | } icon: { 31 | Image(systemName: "bookmark").symbolVariant(.slash) 32 | } 33 | } 34 | .help(Text("Remove bookmark", comment: "Button help text.")) 35 | .accessibilityIdentifier("Unbookmark") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Den.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.net.devsci.den 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.security.app-sandbox 16 | 17 | com.apple.security.application-groups 18 | 19 | group.net.devsci.den 20 | 21 | com.apple.security.files.downloads.read-write 22 | 23 | com.apple.security.files.user-selected.read-write 24 | 25 | com.apple.security.network.client 26 | 27 | com.apple.security.temporary-exception.mach-lookup.global-name 28 | 29 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 30 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /WebExtension/Resources/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "default_locale": "en", 4 | "name": "__MSG_extension_name__", 5 | "description": "__MSG_extension_description__", 6 | "version": "1.0.1", 7 | "icons": { 8 | "48": "images/icon-48.png", 9 | "96": "images/icon-96.png", 10 | "128": "images/icon-128.png", 11 | "256": "images/icon-256.png", 12 | "512": "images/icon-512.png" 13 | }, 14 | "background": { 15 | "scripts": [ "background.js" ], 16 | "persistent": false 17 | }, 18 | "content_scripts": [{ 19 | "js": [ "content.js" ], 20 | "matches": [ "" ] 21 | }], 22 | "browser_action": { 23 | "default_popup": "popup.html", 24 | "default_icon": { 25 | "16": "images/toolbar-icon-16.png", 26 | "19": "images/toolbar-icon-19.png", 27 | "32": "images/toolbar-icon-32.png", 28 | "38": "images/toolbar-icon-38.png", 29 | "48": "images/toolbar-icon-48.png", 30 | "72": "images/toolbar-icon-72.png" 31 | } 32 | }, 33 | "permissions": [] 34 | } 35 | -------------------------------------------------------------------------------- /Den/Views/General/ToggleReadFilterButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleReadFilterButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/13/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ToggleReadFilterButton: View { 12 | @AppStorage("HideRead") private var hideRead: Bool = false 13 | 14 | var body: some View { 15 | Button { 16 | hideRead.toggle() 17 | } label: { 18 | Label { 19 | if hideRead { 20 | Text("Show Read", comment: "Button label.") 21 | } else { 22 | Text("Hide Read", comment: "Button label.") 23 | } 24 | } icon: { 25 | Image(systemName: "line.3.horizontal.decrease") 26 | .symbolVariant(hideRead ? .circle.fill : .circle) 27 | } 28 | } 29 | .help( 30 | hideRead ? Text("Show read items", comment: "Button help text.") : 31 | Text("Hide read items", comment: "Button help text.") 32 | ) 33 | .accessibilityIdentifier("ToggleReadFilter") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /WidgetExtension/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "CFBundleDisplayName" : { 5 | "comment" : "Bundle display name", 6 | "extractionState" : "extracted_with_value", 7 | "localizations" : { 8 | "en" : { 9 | "stringUnit" : { 10 | "state" : "new", 11 | "value" : "Den Widget Extension" 12 | } 13 | } 14 | } 15 | }, 16 | "CFBundleName" : { 17 | "comment" : "Bundle name", 18 | "extractionState" : "extracted_with_value", 19 | "localizations" : { 20 | "en" : { 21 | "stringUnit" : { 22 | "state" : "new", 23 | "value" : "Den Widget Extension" 24 | } 25 | } 26 | } 27 | }, 28 | "NSHumanReadableCopyright" : { 29 | "comment" : "Copyright (human-readable)", 30 | "extractionState" : "extracted_with_value", 31 | "localizations" : { 32 | "en" : { 33 | "stringUnit" : { 34 | "state" : "new", 35 | "value" : "Copyright © 2020-2024 Garrett Johnson" 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | "version" : "1.0" 42 | } -------------------------------------------------------------------------------- /Den/Views/Trending/TrendLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrendLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/20/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TrendLayout: View { 12 | @Environment(\.hideRead) private var hideRead 13 | 14 | @ObservedObject var trend: Trend 15 | 16 | let items: [Item] 17 | 18 | var body: some View { 19 | if items.isEmpty { 20 | AllRead(largeDisplay: true) 21 | } else { 22 | GeometryReader { geometry in 23 | ScrollView(.vertical) { 24 | BoardView(width: geometry.size.width, list: items) { item in 25 | if let feed = item.feedData?.feed { 26 | if feed.wrappedPreviewStyle == .expanded { 27 | FeedItemExpanded(item: item, feed: feed) 28 | } else { 29 | FeedItemCompressed(item: item, feed: feed) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Data/Tasks/CleanupTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanupTask.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import OSLog 11 | 12 | struct CleanupTask { 13 | static func execute() async { 14 | let context = DataController.shared.container.newBackgroundContext() 15 | 16 | context.performAndWait { 17 | guard let feedDatas = try? context.fetch(FeedData.fetchRequest()) as [FeedData] else { 18 | Logger.main.error("Unable to fetch FeedData records for cleanup") 19 | return 20 | } 21 | 22 | var orphansPurged = 0 23 | for feedData in feedDatas where feedData.feed == nil { 24 | context.delete(feedData) 25 | orphansPurged += 1 26 | } 27 | 28 | do { 29 | try context.save() 30 | Logger.main.info("Purged \(orphansPurged) orphaned feed data records.") 31 | } catch { 32 | CrashUtility.handleCriticalError(error as NSError) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Den/UI/WebViewError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebViewError.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/18/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct WebViewError { 12 | var error: Error 13 | 14 | var html: String { 15 | """ 16 | 17 | 18 | Error 19 | 41 | 42 | 43 |
44 |

⚠️

45 |

46 | \(error.localizedDescription) 47 |

48 |
49 | 50 | 51 | """ 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Den/Views/Preview/SmallThumbnail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmallThumbnail.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 4/17/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import SDWebImageSwiftUI 12 | 13 | struct SmallThumbnail: View { 14 | @Environment(\.displayScale) private var displayScale 15 | 16 | let url: URL 17 | let isRead: Bool 18 | 19 | @ScaledMetric private var size = 80 20 | 21 | var body: some View { 22 | WebImage( 23 | url: url, 24 | options: [.decodeFirstFrameOnly], 25 | context: [ 26 | .imageThumbnailPixelSize: CGSize( 27 | width: size * displayScale, 28 | height: size * displayScale 29 | ) 30 | ] 31 | ) { image in 32 | image.resizable().scaledToFill() 33 | } placeholder: { 34 | ImageErrorPlaceholder() 35 | } 36 | .modifier(PreviewImageStateModifier(isRead: isRead)) 37 | .frame(width: size, height: size) 38 | .background(.fill.tertiary) 39 | .modifier(ImageBorderModifier(cornerRadius: 6)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Den/Extensions/FeedKit/AtomFeed+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AtomFeed+Extensions.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/18/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import FeedKit 12 | 13 | extension AtomFeed: WebFeed { 14 | var webpage: URL? { 15 | if 16 | let link = self.links?.first(where: { 17 | $0.attributes?.rel == "alternate" || $0.attributes?.rel == nil 18 | }), 19 | let urlString = link.attributes?.href?.trimmingCharacters(in: .whitespacesAndNewlines), 20 | let webpage = URL(string: urlString) 21 | { 22 | return webpage 23 | } 24 | 25 | if 26 | let id = self.id?.trimmingCharacters(in: .whitespacesAndNewlines), 27 | let webpage = URL(string: id) 28 | { 29 | return webpage 30 | } 31 | 32 | return nil 33 | } 34 | 35 | var entriesSortedByDateAndTitle: [AtomFeedEntry]? { 36 | entries?.sorted(using: [ 37 | SortDescriptor(\.published, order: .reverse), 38 | SortDescriptor(\.title) 39 | ]) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Shared/Entities/Tag+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tag+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/12/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | @objc(Tag) 13 | final public class Tag: NSManagedObject { 14 | var displayName: Text { 15 | if wrappedName == "" { 16 | return Text("Untitled", comment: "Default tag name.") 17 | } 18 | 19 | return Text(wrappedName) 20 | } 21 | 22 | var wrappedName: String { 23 | get { name?.trimmingCharacters(in: .whitespaces) ?? "" } 24 | set { name = newValue } 25 | } 26 | 27 | static func create( 28 | in managedObjectContext: NSManagedObjectContext, 29 | userOrder: Int16 30 | ) -> Tag { 31 | let tag = self.init(context: managedObjectContext) 32 | tag.id = UUID() 33 | tag.userOrder = userOrder 34 | 35 | return tag 36 | } 37 | } 38 | 39 | extension Collection where Element == Tag { 40 | var maxUserOrder: Int16 { 41 | self.reduce(0) { partialResult, tag in 42 | tag.userOrder > partialResult ? tag.userOrder : partialResult 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Den/Views/Item/ToggleReadButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleReadButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ToggleReadButton: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | 14 | @ObservedObject var item: Item 15 | 16 | var body: some View { 17 | Button { 18 | if item.read { 19 | HistoryUtility.markItemUnread(context: viewContext, item: item) 20 | } else { 21 | HistoryUtility.markItemRead(context: viewContext, item: item) 22 | } 23 | } label: { 24 | Label { 25 | if item.read { 26 | Text("Mark Unread", comment: "Button label.") 27 | } else { 28 | Text("Mark Read", comment: "Button label.") 29 | } 30 | } icon: { 31 | if item.read { 32 | Image(systemName: "checkmark.circle.badge.xmark") 33 | } else { 34 | Image(systemName: "checkmark.circle") 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Den/UI/Styles/ContentBlockButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentBlockButtonStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 8/12/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ContentBlockButtonStyle: ButtonStyle { 12 | @Environment(\.colorScheme) private var colorScheme 13 | 14 | func makeBody(configuration: Self.Configuration) -> some View { 15 | #if os(macOS) 16 | if colorScheme == .dark { 17 | configuration.label 18 | .background(.fill.quaternary) 19 | .overlay { 20 | clipShape.strokeBorder(.separator) 21 | } 22 | .clipShape(clipShape) 23 | .background(.background) 24 | } else { 25 | configuration.label 26 | .background(.background) 27 | .clipShape(clipShape) 28 | } 29 | #else 30 | configuration.label 31 | .background(Color(.secondarySystemGroupedBackground)) 32 | .clipShape(clipShape) 33 | #endif 34 | } 35 | 36 | private var clipShape: some InsettableShape { 37 | RoundedRectangle(cornerRadius: 8) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Den/Views/General/AllRead.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AllRead.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/5/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AllRead: View { 12 | var largeDisplay: Bool = false 13 | 14 | var body: some View { 15 | if largeDisplay { 16 | ContentUnavailable { 17 | Label { 18 | Text("All Read", comment: "Content unavailable title.") 19 | } icon: { 20 | Image(systemName: "checkmark") 21 | } 22 | } description: { 23 | Text( 24 | """ 25 | Turn off filter \(Image(systemName: "line.3.horizontal.decrease.circle")) \ 26 | to show hidden items. 27 | """, 28 | comment: "All read guidance." 29 | ) 30 | } 31 | } else { 32 | CompactContentUnavailable { 33 | Label { 34 | Text("All Read", comment: "Card note title.") 35 | } icon: { 36 | Image(systemName: "checkmark") 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Den/UI/MercuryObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MercuryObject.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/10/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // swiftlint:disable identifier_name 12 | struct MercuryObject: Codable, Equatable { 13 | var title: String? 14 | var content: String? 15 | var author: String? 16 | var date_published: Date? 17 | var lead_image_url: String? 18 | var dek: String? 19 | var next_page_url: String? 20 | var url: String? 21 | var domain: String? 22 | var excerpt: String? 23 | var word_count: Int? 24 | var direction: String? 25 | var total_pages: Int? 26 | var rendered_pages: Int? 27 | 28 | static func == (lhs: MercuryObject, rhs: MercuryObject) -> Bool { 29 | return lhs.url == rhs.url 30 | } 31 | 32 | var cleanedContent: String? { 33 | guard let content = content else { return nil } 34 | 35 | return HTMLContent(source: content).sanitizedHTML() 36 | } 37 | 38 | var textContent: String? { 39 | guard let content = content else { return nil } 40 | 41 | return HTMLContent(source: content).plainText() 42 | } 43 | } 44 | // swiftlint:enable identifier_name 45 | -------------------------------------------------------------------------------- /WidgetExtension/SDWebImageManager+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDWebImage+Extensions.swift 3 | // Widget Extension 4 | // 5 | // Created by Garrett Johnson on 5/6/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import SDWebImage 12 | 13 | extension SDWebImageManager { 14 | func loadImage( 15 | with url: URL?, 16 | options: SDWebImageOptions? = nil, 17 | context: [SDWebImageContextOption: Any]? = nil 18 | ) async -> (Image?, Data?) { 19 | return await withCheckedContinuation { continuation in 20 | SDWebImageManager.shared.loadImage( 21 | with: url, 22 | options: options ?? [], 23 | context: context, 24 | progress: nil 25 | ) { image, data, _, _, _, _ in 26 | var platformImage: Image? 27 | if let image = image { 28 | #if os(macOS) 29 | platformImage = Image(nsImage: image) 30 | #else 31 | platformImage = Image(uiImage: image) 32 | #endif 33 | } 34 | continuation.resume(returning: (platformImage, data)) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Den/Views/Item/ItemPreviewCompressed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemPreviewCompressed.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/29/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ItemPreviewCompressed: View { 12 | @ObservedObject var item: Item 13 | @ObservedObject var feed: Feed 14 | 15 | var body: some View { 16 | HStack(alignment: .top) { 17 | VStack(alignment: .leading, spacing: 4) { 18 | PreviewHeadline(title: item.titleText) 19 | VStack(alignment: .leading, spacing: 2) { 20 | ItemMeta(item: item) 21 | if !feed.hideBylines, let author = item.author { 22 | PreviewAuthor(author: author) 23 | } 24 | } 25 | if let teaser = item.teaser, teaser != "" && !feed.hideTeasers { 26 | PreviewTeaser(teaser: teaser) 27 | } 28 | } 29 | 30 | Spacer(minLength: 0) 31 | 32 | if !feed.hideImages, let url = item.image { 33 | SmallThumbnail(url: url, isRead: item.read).padding(.leading, 12) 34 | } 35 | } 36 | .padding(12) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Den/UI/Enumerations/WebAddressValidationMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebAddressValidationMessage.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/20/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | enum WebAddressValidationMessage { 12 | case cannotBeBlank 13 | case mustNotContainSpaces 14 | case mustBeginWithHTTP 15 | case parseError 16 | 17 | var text: Text { 18 | switch self { 19 | case .cannotBeBlank: 20 | return Text( 21 | "Address cannot be blank.", 22 | comment: "URL field validation message." 23 | ) 24 | case .mustNotContainSpaces: 25 | return Text( 26 | "Address must not contain spaces.", 27 | comment: "URL field validation message." 28 | ) 29 | case .mustBeginWithHTTP: 30 | return Text( 31 | "Address must begin with “http://” or “https://”.", 32 | comment: "URL field validation message." 33 | ) 34 | case .parseError: 35 | return Text( 36 | "Address could not be parsed.", 37 | comment: "URL field validation message." 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Den/Utilities/CleanupUtility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanupUtility.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/22/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import OSLog 11 | 12 | struct CleanupUtility { 13 | static func upgradeBookmarks(context: NSManagedObjectContext) { 14 | let request = Bookmark.fetchRequest() 15 | request.predicate = NSPredicate(format: "created = nil") 16 | 17 | guard 18 | let bookmarks = try? context.fetch(request) as [Bookmark], 19 | !bookmarks.isEmpty 20 | else { 21 | return 22 | } 23 | 24 | for bookmark in bookmarks { 25 | bookmark.created = bookmark.published ?? bookmark.ingested ?? Date(timeIntervalSince1970: 0) 26 | 27 | bookmark.site = bookmark.feed?.title 28 | bookmark.favicon = bookmark.feed?.feedData?.favicon 29 | bookmark.hideImage = bookmark.feed?.hideImages ?? false 30 | bookmark.hideByline = bookmark.feed?.hideBylines ?? false 31 | bookmark.hideTeaser = bookmark.feed?.hideTeasers ?? false 32 | bookmark.largePreview = bookmark.feed?.largePreviews ?? false 33 | } 34 | 35 | try? context.save() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Den/Views/General/RelativeRefreshedDate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelativeRefreshedDate.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/29/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RelativeRefreshedDate: View { 12 | let timestamp: Double 13 | 14 | var date: Date { 15 | return Date(timeIntervalSince1970: timestamp) 16 | } 17 | 18 | static let formatStyle: Date.RelativeFormatStyle = .relative( 19 | presentation: .numeric, 20 | unitsStyle: .wide 21 | ) 22 | 23 | var body: some View { 24 | TimelineView(.everyMinute) { _ in 25 | Group { 26 | if -date.timeIntervalSinceNow < 60 { 27 | Text( 28 | "Updated Just Now", 29 | comment: "Status message (refreshed less than one minute ago)." 30 | ) 31 | } else { 32 | Text( 33 | "Updated \(date.formatted(RelativeRefreshedDate.formatStyle))", 34 | comment: "Status message (relative date display)." 35 | ) 36 | } 37 | } 38 | .help(Text(date.formatted(date: .complete, time: .shortened))) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Den/Views/Settings/UserColorSchemePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserColorSchemePicker.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 6/15/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct UserColorSchemePicker: View { 12 | @Environment(\.colorScheme) private var colorScheme 13 | 14 | @Binding var userColorScheme: UserColorScheme 15 | 16 | var body: some View { 17 | Picker(selection: $userColorScheme) { 18 | Group { 19 | Text("System", comment: "User color scheme picker option.") 20 | .tag(UserColorScheme.system) 21 | Text("Light", comment: "User color scheme picker option.") 22 | .tag(UserColorScheme.light) 23 | Text("Dark", comment: "User color scheme picker option.") 24 | .tag(UserColorScheme.dark) 25 | } 26 | .foregroundStyle(colorScheme == .dark ? .white : .black) 27 | 28 | } label: { 29 | Label { 30 | Text("Theme", comment: "Picker label.") 31 | } icon: { 32 | Image(systemName: "paintpalette") 33 | } 34 | } 35 | #if os(iOS) 36 | .pickerStyle(.navigationLink) 37 | #endif 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Den/Views/General/MarkAllReadUnreadButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkAllReadUnreadButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/13/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MarkAllReadUnreadButton: View { 12 | let allRead: Bool 13 | let toggleAll: () async -> Void 14 | 15 | @State private var toggling = false 16 | 17 | var body: some View { 18 | Button { 19 | toggling = true 20 | Task { 21 | await toggleAll() 22 | toggling = false 23 | } 24 | } label: { 25 | Label { 26 | if allRead { 27 | Text("Mark All Unread", comment: "Button label.") 28 | } else { 29 | Text("Mark All Read", comment: "Button label.") 30 | } 31 | } icon: { 32 | Image(systemName: "checkmark.circle").symbolVariant(allRead ? .fill : .none) 33 | } 34 | } 35 | .disabled(toggling) 36 | .help( 37 | allRead ? Text("Mark all items unread", comment: "Button help text.") : 38 | Text("Mark all items read", comment: "Button help text.") 39 | ) 40 | .accessibilityIdentifier("MarkAllReadUnread") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /UITests/SettingsUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsUITests.swift 3 | // UI Tests 4 | // 5 | // Created by Garrett Johnson on 10/24/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class SettingsUITests: UITestCase { 12 | #if os(macOS) 13 | @MainActor 14 | func testAppSettings() throws { 15 | let app = launchApp(inMemory: false) 16 | 17 | app.menuBarItems["Den"].menuItems["Settings…"].tap() 18 | 19 | attachScreenshot(of: app.windows.element(boundBy: 1), named: "settings") 20 | } 21 | #else 22 | @MainActor 23 | func testSettings() throws { 24 | let app = launchApp(inMemory: false) 25 | 26 | if !app.buttons["SidebarMenu"].waitForExistence(timeout: 2) { 27 | XCTFail("Sidebar menu button did not appear in time") 28 | } 29 | app.buttons["SidebarMenu"].tap() 30 | if !app.buttons["Settings"].waitForExistence(timeout: 2) { 31 | XCTFail("Settings button did not appear in time") 32 | } 33 | app.buttons["Settings"].tap() 34 | 35 | if !app.staticTexts["Settings"].waitForExistence(timeout: 2) { 36 | XCTFail("Settings header did not appear in time") 37 | } 38 | 39 | attachScreenshot(of: app.windows.firstMatch, named: "settings") 40 | } 41 | #endif 42 | } 43 | -------------------------------------------------------------------------------- /Den/Views/General/InspectorToggleButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InspectorToggleButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/18/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InspectorToggleButton: View { 12 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 13 | 14 | @Binding var showingInspector: Bool 15 | 16 | var body: some View { 17 | Button { 18 | showingInspector.toggle() 19 | } label: { 20 | Label { 21 | if showingInspector { 22 | Text("Hide Inspector", comment: "Button label.") 23 | } else { 24 | Text("Show Inspector", comment: "Button label.") 25 | } 26 | } icon: { 27 | if horizontalSizeClass == .compact { 28 | Image(systemName: "rectangle.portrait.bottomhalf.inset.filled") 29 | } else { 30 | Image(systemName: "sidebar.trailing") 31 | } 32 | } 33 | } 34 | .help( 35 | showingInspector ? Text("Hide Inspector", comment: "Button help text.") : 36 | Text("Show Inspector", comment: "Button help text.") 37 | ) 38 | .accessibilityIdentifier("ToggleInspector") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Den/Views/Item/ItemPreviewExpanded.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemPreviewExpanded.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/10/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ItemPreviewExpanded: View { 12 | @ObservedObject var item: Item 13 | @ObservedObject var feed: Feed 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 4) { 17 | PreviewHeadline(title: item.titleText) 18 | VStack(alignment: .leading, spacing: 2) { 19 | ItemMeta(item: item) 20 | if !feed.hideBylines, let author = item.author { 21 | PreviewAuthor(author: author) 22 | } 23 | } 24 | if !feed.hideImages, let url = item.image { 25 | LargeThumbnail( 26 | url: url, 27 | isRead: item.read, 28 | sourceWidth: CGFloat(item.imageWidth), 29 | sourceHeight: CGFloat(item.imageHeight) 30 | ) 31 | } 32 | if let teaser = item.teaser, teaser != "" && !feed.hideTeasers { 33 | PreviewTeaser(teaser: teaser) 34 | } 35 | } 36 | .multilineTextAlignment(.leading) 37 | .frame(maxWidth: .infinity, alignment: .leading) 38 | .padding(12) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/Start.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Start.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/1/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | struct Start: View { 13 | @Environment(\.managedObjectContext) private var viewContext 14 | 15 | var body: some View { 16 | Section { 17 | NewPageButton() 18 | ImportButton() 19 | Button { 20 | loadDemo() 21 | } label: { 22 | Label { 23 | Text("Load Demo", comment: "Button label.") 24 | .multilineTextAlignment(.leading) 25 | } icon: { 26 | Image(systemName: "wand.and.stars") 27 | } 28 | } 29 | .accessibilityIdentifier("LoadDemo") 30 | } header: { 31 | Text("Get Started", comment: "Sidebar section header.") 32 | } 33 | } 34 | 35 | private func loadDemo() { 36 | guard let demoPath = Bundle.main.path(forResource: "Demo", ofType: "opml") else { 37 | preconditionFailure("Missing demo feeds source file") 38 | } 39 | 40 | ImportExportUtility.importOPML( 41 | url: URL(fileURLWithPath: demoPath), 42 | context: viewContext, 43 | pageUserOrderMax: 0 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Den/Views/Browser/StopReloadButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StopReloadButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/19/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StopReloadButton: View { 12 | @ObservedObject var browserViewModel: BrowserViewModel 13 | 14 | var body: some View { 15 | if browserViewModel.isLoading { 16 | Button { 17 | browserViewModel.stop() 18 | } label: { 19 | Label { 20 | Text("Stop", comment: "Button label.") 21 | } icon: { 22 | Image(systemName: "xmark") 23 | } 24 | } 25 | .help(Text("Stop loading page", comment: "Button help text.")) 26 | .accessibilityIdentifier("BrowserStop") 27 | } else { 28 | Button { 29 | browserViewModel.reload() 30 | } label: { 31 | Label { 32 | Text("Reload", comment: "Button label.") 33 | } icon: { 34 | Image(systemName: "arrow.clockwise") 35 | } 36 | } 37 | .keyboardShortcut("r", modifiers: [.control, .command]) 38 | .help(Text("Reload page", comment: "Button help text.")) 39 | .accessibilityIdentifier("BrowserReload") 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /UITests/AppLaunchUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppLaunchUITests.swift 3 | // UITests 4 | // 5 | // Created by Garrett Johnson on 7/13/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class AppLaunchUITests: UITestCase { 12 | @MainActor 13 | func testPosterScreenshot() throws { 14 | let app = launchApp(inMemory: false) 15 | 16 | if !app.staticTexts["InboxNavLink"].waitForExistence(timeout: 10) { 17 | XCTFail("Inbox button did not appear in time") 18 | } 19 | 20 | #if os(macOS) 21 | app.disclosureTriangles.element(boundBy: 3).tap() 22 | app.staticTexts.matching(identifier: "SidebarPage").element(boundBy: 3).tap() 23 | #else 24 | if UIDevice.current.userInterfaceIdiom == .pad { 25 | app.collectionViews["Sidebar"].cells.element(boundBy: 7).buttons.firstMatch.tap() 26 | app.staticTexts.matching(identifier: "SidebarPage").element(boundBy: 3).tap() 27 | } else { 28 | app.collectionViews["Sidebar"] 29 | .cells 30 | .element(boundBy: 7) 31 | .buttons 32 | .element(boundBy: 1) 33 | .tap() 34 | } 35 | #endif 36 | 37 | // Wait for images to load 38 | sleep(8) 39 | 40 | attachScreenshot(of: app.windows.firstMatch, named: "app-poster") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Shared/Entities/Blocklist+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Blocklist+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/13/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | @objc(Blocklist) 13 | final public class Blocklist: NSManagedObject { 14 | var nameText: Text { 15 | if wrappedName == "" { 16 | return Text("Untitled", comment: "Default content filter name.") 17 | } 18 | 19 | return Text(wrappedName) 20 | } 21 | 22 | var wrappedName: String { 23 | get { name ?? "" } 24 | set { name = newValue } 25 | } 26 | 27 | var urlString: String { 28 | get { url?.absoluteString ?? "" } 29 | set { url = URL(string: newValue) } 30 | } 31 | 32 | var blocklistStatus: BlocklistStatus? { 33 | guard let id = self.id else { return nil } 34 | 35 | let request = BlocklistStatus.fetchRequest() 36 | request.predicate = NSPredicate(format: "blocklistId == %@", id as CVarArg) 37 | request.fetchLimit = 1 38 | 39 | return try? self.managedObjectContext?.fetch(request).first 40 | } 41 | 42 | static func create(in managedObjectContext: NSManagedObjectContext) -> Blocklist { 43 | let blocklist = self.init(context: managedObjectContext) 44 | blocklist.id = UUID() 45 | 46 | return blocklist 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Den/Views/Page/PageLayoutPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageLayoutPicker.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/28/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PageLayoutPicker: View { 12 | @Binding var pageLayout: PageLayout 13 | 14 | var body: some View { 15 | Picker(selection: $pageLayout) { 16 | Label { 17 | Text("Grouped", comment: "Layout option label.") 18 | } icon: { 19 | Image(systemName: "rectangle.grid.3x2") 20 | } 21 | .tag(PageLayout.grouped) 22 | .accessibilityIdentifier("GroupedLayout") 23 | 24 | Label { 25 | Text("Deck", comment: "Layout option label.") 26 | } icon: { 27 | Image(systemName: "rectangle.split.3x1") 28 | } 29 | .tag(PageLayout.deck) 30 | .accessibilityIdentifier("DeckLayout") 31 | 32 | Label { 33 | Text("Timeline", comment: "Layout option label.") 34 | } icon: { 35 | Image(systemName: "calendar.day.timeline.leading") 36 | } 37 | .tag(PageLayout.timeline) 38 | .accessibilityIdentifier("TimelineLayout") 39 | } label: { 40 | Text("View", comment: "Picker label.") 41 | } 42 | .accessibilityIdentifier("PageLayoutPicker") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Den/Data/BlocklistManifest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlocklistManifest.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/26/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | struct BlocklistManifest { 13 | struct ManifestCollection: Decodable, Identifiable, Hashable { 14 | var id: String 15 | var name: String 16 | var website: URL 17 | var filterLists: [ManifestItem] 18 | } 19 | 20 | struct ManifestItem: Decodable, Identifiable, Hashable { 21 | var id: String 22 | var name: String 23 | var description: String 24 | var convertedURL: URL 25 | var sourceURL: URL 26 | var supportURL: URL 27 | var convertedCount: Int = 0 28 | var errorsCount: Int = 0 29 | } 30 | 31 | static let url = URL(string: "https://blocklists.den.io/manifest.json")! 32 | 33 | static func fetch() async -> [ManifestCollection] { 34 | guard let (data, _) = try? await URLSession.shared.data(from: url) else { 35 | Logger.main.error("Unable to fetch blocklist source manifest") 36 | return [] 37 | } 38 | 39 | do { 40 | return try JSONDecoder().decode([ManifestCollection].self, from: data) 41 | } catch { 42 | Logger.main.error("Unable to decode blocklist source manifest: \(error)") 43 | return [] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Den/UI/Styles/RefreshProgressViewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshProgressViewStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/3/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RefreshProgressViewStyle: ProgressViewStyle { 12 | let feedCount: Int 13 | 14 | func makeBody(configuration: Configuration) -> some View { 15 | if let fractionCompleted = configuration.fractionCompleted { 16 | VStack { 17 | if fractionCompleted < 1.0 { 18 | Text( 19 | """ 20 | \(Int(fractionCompleted * Double(feedCount))) \ 21 | of \(feedCount) Updated 22 | """, 23 | comment: "Status message (refresh in progress)." 24 | ) 25 | .monospacedDigit() 26 | } else { 27 | Text("Analyzing…", comment: "Status message (analysis in progress).") 28 | } 29 | 30 | #if os(iOS) 31 | ZStack(alignment: .leading) { 32 | Capsule().fill(.quaternary) 33 | Capsule() 34 | .fill(.tint) 35 | .frame(width: fractionCompleted * 132) 36 | } 37 | .frame(width: 132, height: 6) 38 | #endif 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Den/Views/Feed/FeedHero.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedHero.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/9/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import SDWebImageSwiftUI 12 | 13 | struct FeedHero: View { 14 | let url: URL 15 | 16 | @ScaledMetric private var height = 200 17 | 18 | var body: some View { 19 | WebImage(url: url, options: [.decodeFirstFrameOnly, .delayPlaceholder]) { image in 20 | VStack(spacing: 0) { 21 | ZStack { 22 | image 23 | .resizable() 24 | .scaledToFit() 25 | .clipShape(RoundedRectangle(cornerRadius: 8)) 26 | .shadow(color: .black.opacity(0.25), radius: 3, y: 1) 27 | .padding() 28 | } 29 | .frame(maxWidth: .infinity, maxHeight: .infinity) 30 | .background { 31 | image 32 | .resizable() 33 | .scaledToFill() 34 | .background(.background) 35 | .overlay(.thinMaterial) 36 | } 37 | .clipped() 38 | Divider() 39 | } 40 | } placeholder: { 41 | VStack { 42 | ImageErrorPlaceholder() 43 | Divider() 44 | } 45 | } 46 | .frame(height: height) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Den/Views/Item/FeedItemCompressed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedItemCompressed.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 2/27/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedItemCompressed: View { 12 | @ObservedObject var item: Item 13 | @ObservedObject var feed: Feed 14 | 15 | var body: some View { 16 | ItemActionView(item: item, isLastInList: true, isStandalone: true) { 17 | HStack(alignment: .top) { 18 | VStack(alignment: .leading, spacing: 4) { 19 | FeedTitleLabel(feed: feed).font(.callout).imageScale(.small) 20 | PreviewHeadline(title: item.titleText) 21 | VStack(alignment: .leading, spacing: 2) { 22 | ItemMeta(item: item) 23 | if !feed.hideBylines, let author = item.author { 24 | PreviewAuthor(author: author) 25 | } 26 | } 27 | if let teaser = item.teaser, teaser != "" && !feed.hideTeasers { 28 | PreviewTeaser(teaser: teaser) 29 | } 30 | } 31 | 32 | Spacer(minLength: 0) 33 | 34 | if !feed.hideImages, let url = item.image { 35 | SmallThumbnail(url: url, isRead: item.read).padding(.leading, 12) 36 | } 37 | } 38 | .padding(12) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Den/Views/Organizer/OrganizerRowStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrganizerRowStatus.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/23/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct OrganizerRowStatus: View { 12 | @ObservedObject var feed: Feed 13 | @ObservedObject var feedData: FeedData 14 | 15 | var body: some View { 16 | if let error = feedData.wrappedError { 17 | 18 | switch error { 19 | case .parsing: 20 | Label { 21 | Text("Parsing Error", comment: "Organizer row status.") 22 | } icon: { 23 | Image(systemName: "bolt.horizontal").foregroundStyle(.orange) 24 | } 25 | case .request: 26 | Label { 27 | Text("Network Error", comment: "Organizer row status.") 28 | } icon: { 29 | Image(systemName: "network.slash").foregroundStyle(.orange) 30 | } 31 | } 32 | } else { 33 | if feedData.responseTime > 5 { 34 | Image(systemName: "tortoise").foregroundStyle(.brown) 35 | } else if !feed.isSecure { 36 | Image(systemName: "lock.slash").foregroundStyle(.yellow) 37 | } 38 | Text( 39 | "\(Int(feedData.responseTime * 1000)) ms", 40 | comment: "Milliseconds time display." 41 | ) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /WidgetExtension/Common/WidgetAppIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetAppIcon.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/5/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct WidgetAppIcon: View { 12 | @Environment(\.widgetRenderingMode) var widgetRenderingMode 13 | 14 | @ScaledMetric(relativeTo: .largeTitle) var size = 16 15 | 16 | var body: some View { 17 | Group { 18 | if #available(iOS 18.0, macOS 15.0, *) { 19 | if widgetRenderingMode == .fullColor { 20 | Rectangle() 21 | .fill(.tint) 22 | .mask(alignment: .center) { 23 | Image("WidgetIcon") 24 | .resizable() 25 | .scaledToFit() 26 | } 27 | } else { 28 | Image("WidgetIcon") 29 | .resizable() 30 | .widgetAccentedRenderingMode(.accentedDesaturated) 31 | } 32 | } else { 33 | Rectangle() 34 | .fill(.tint) 35 | .mask(alignment: .center) { 36 | Image("WidgetIcon") 37 | .resizable() 38 | .scaledToFit() 39 | } 40 | } 41 | } 42 | .frame(width: size, height: size) 43 | .offset(y: -2) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Shared/Entities/Trend+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Trend+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/23/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | 12 | @objc(Trend) 13 | final public class Trend: NSManagedObject { 14 | var titleText: Text { 15 | if let title = title { 16 | return Text(title) 17 | } else { 18 | return Text("Untitled", comment: "Default trend title.") 19 | } 20 | } 21 | 22 | var trendItemsArray: [TrendItem] { 23 | trendItems?.allObjects as? [TrendItem] ?? [] 24 | } 25 | 26 | var items: [Item] { 27 | trendItemsArray 28 | .compactMap { $0.item } 29 | .sorted(using: SortDescriptor(\.published, order: .reverse)) 30 | } 31 | 32 | var feeds: [Feed] { 33 | Set(items.compactMap { $0.feedData?.feed }).sorted { $0.wrappedTitle < $1.wrappedTitle } 34 | } 35 | 36 | func updateReadStatus() { 37 | read = items.unread.isEmpty 38 | } 39 | 40 | static func create(in managedObjectContext: NSManagedObjectContext) -> Trend { 41 | let trend = self.init(context: managedObjectContext) 42 | trend.id = UUID() 43 | 44 | return trend 45 | } 46 | } 47 | 48 | extension Collection where Element == Trend { 49 | var containingUnread: [Trend] { 50 | self.filter { !$0.read } 51 | } 52 | 53 | var items: [Item] { 54 | return self.flatMap { $0.items }.uniqueElements() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Den/Views/Bookmark/Bookmarks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bookmarks.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/12/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Bookmarks: View { 12 | @FetchRequest(sortDescriptors: [SortDescriptor(\.created, order: .reverse)]) 13 | private var bookmarks: FetchedResults 14 | 15 | @AppStorage("BookmarksLayout") private var layout: BookmarksLayout = .previews 16 | 17 | var body: some View { 18 | Group { 19 | if bookmarks.isEmpty { 20 | ContentUnavailable { 21 | Label { 22 | Text("No Bookmarks", comment: "Content unavailable title.") 23 | } icon: { 24 | Image(systemName: "book") 25 | } 26 | } 27 | } else if layout == .list { 28 | BookmarksTableLayout(bookmarks: bookmarks) 29 | } else { 30 | BookmarksSpreadLayout(bookmarks: bookmarks) 31 | } 32 | } 33 | .navigationTitle(Text("Bookmarks", comment: "Navigation title.")) 34 | .toolbar { 35 | ToolbarItem { 36 | BookmarksLayoutPicker(layout: $layout) 37 | #if os(macOS) 38 | .pickerStyle(.inline) 39 | #else 40 | .pickerStyle(.menu) 41 | .labelStyle(.iconOnly) 42 | .padding(.trailing, -12) 43 | #endif 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Den/Views/General/Favicon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Favicon.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/6/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import SDWebImageSwiftUI 12 | 13 | struct Favicon: View { 14 | @Environment(\.displayScale) private var displayScale 15 | @Environment(\.imageScale) private var imageScale 16 | 17 | let url: URL? 18 | 19 | @ViewBuilder var placeholder: Placeholder 20 | 21 | @ScaledMetric private var smallSize = 12 22 | @ScaledMetric private var mediumSize = 16 23 | @ScaledMetric private var largeSize = 20 24 | 25 | var body: some View { 26 | WebImage( 27 | url: url, 28 | options: [.decodeFirstFrameOnly, .delayPlaceholder], 29 | context: [.imageThumbnailPixelSize: thumbnailPixelSize] 30 | ) { image in 31 | image.resizable().scaledToFit() 32 | } placeholder: { 33 | placeholder 34 | } 35 | .frame(width: size, height: size) 36 | .clipShape(RoundedRectangle(cornerRadius: 2)) 37 | } 38 | 39 | private var size: CGFloat { 40 | switch imageScale { 41 | case .small: 42 | smallSize 43 | case .medium: 44 | mediumSize 45 | case .large: 46 | largeSize 47 | @unknown default: 48 | mediumSize 49 | } 50 | } 51 | 52 | private var thumbnailPixelSize: CGSize { 53 | CGSize(width: size * displayScale, height: size * displayScale) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /WidgetExtension/LatestItems/LatestItemsEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestItemsEntry.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/5/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | import WidgetKit 12 | 13 | struct LatestItemsEntry: TimelineEntry { 14 | struct WidgetItem: Identifiable { 15 | var id: UUID 16 | var itemTitle: String 17 | var feedTitle: String 18 | var faviconURL: URL? 19 | var faviconImage: Image? 20 | var thumbnailURL: URL? 21 | var thumbnailImage: Image? 22 | } 23 | 24 | var date: Date 25 | var items: [WidgetItem] 26 | var sourceID: UUID? 27 | var sourceType: NSManagedObject.Type? 28 | var unread: Int 29 | var title: Text 30 | var faviconURL: URL? 31 | var faviconImage: Image? 32 | var symbol: String? 33 | var configuration: LatestItemsConfigurationIntent 34 | 35 | func url(item: WidgetItem? = nil) -> URL { 36 | var source = "inbox" 37 | if sourceType == Feed.self { 38 | source = "feed" 39 | } else if sourceType == Page.self { 40 | source = "page" 41 | } 42 | 43 | var url = URL(string: "den+widget://latest-items/\(source)")! 44 | 45 | if let sourceID = sourceID { 46 | url.append(path: "\(sourceID.uuidString)") 47 | } 48 | 49 | if let item = item { 50 | url.append(queryItems: [.init(name: "item", value: item.id.uuidString)]) 51 | } 52 | 53 | return url 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Den/Views/Page/TimelineLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/15/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TimelineLayout: View { 12 | @Environment(\.hideRead) private var hideRead 13 | 14 | @ObservedObject var page: Page 15 | 16 | let items: FetchedResults 17 | 18 | var body: some View { 19 | if items.isEmpty { 20 | ContentUnavailable { 21 | Label { 22 | Text("No Items", comment: "Content unavailable title.") 23 | } icon: { 24 | Image(systemName: "folder") 25 | } 26 | } 27 | } else if items.unread.isEmpty && hideRead { 28 | AllRead(largeDisplay: true) 29 | } else { 30 | GeometryReader { geometry in 31 | ScrollView(.vertical) { 32 | BoardView( 33 | width: geometry.size.width, 34 | list: items.visibilityFiltered(hideRead ? false : nil) 35 | ) { item in 36 | if let feed = item.feedData?.feed { 37 | if feed.wrappedPreviewStyle == .expanded { 38 | FeedItemExpanded(item: item, feed: feed) 39 | } else { 40 | FeedItemCompressed(item: item, feed: feed) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Den/Views/Item/FeedItemExpanded.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedItemExpanded.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/28/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedItemExpanded: View { 12 | @ObservedObject var item: Item 13 | @ObservedObject var feed: Feed 14 | 15 | var body: some View { 16 | ItemActionView(item: item, isLastInList: true, isStandalone: true) { 17 | VStack(alignment: .leading, spacing: 4) { 18 | FeedTitleLabel(feed: feed).font(.callout).imageScale(.small) 19 | PreviewHeadline(title: item.titleText) 20 | VStack(alignment: .leading, spacing: 2) { 21 | ItemMeta(item: item) 22 | if !feed.hideBylines, let author = item.author { 23 | PreviewAuthor(author: author) 24 | } 25 | } 26 | if !feed.hideImages, let url = item.image { 27 | LargeThumbnail( 28 | url: url, 29 | isRead: item.read, 30 | sourceWidth: CGFloat(item.imageWidth), 31 | sourceHeight: CGFloat(item.imageHeight) 32 | ) 33 | } 34 | if let teaser = item.teaser, teaser != "" && !feed.hideTeasers { 35 | PreviewTeaser(teaser: teaser) 36 | } 37 | } 38 | .multilineTextAlignment(.leading) 39 | .frame(maxWidth: .infinity, alignment: .leading) 40 | .padding(12) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Shared/Entities/FeedData+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Feed+CoreDataClass.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/30/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import FeedKit 11 | import OSLog 12 | import SwiftUI 13 | 14 | @objc(FeedData) 15 | final public class FeedData: NSManagedObject { 16 | enum RefreshError: String { 17 | case request 18 | case parsing 19 | } 20 | 21 | var wrappedError: RefreshError? { 22 | get { 23 | guard let error = error else { return nil } 24 | return RefreshError(rawValue: error) 25 | } 26 | set { 27 | error = newValue?.rawValue 28 | } 29 | } 30 | 31 | var feed: Feed? { 32 | guard let feedID = self.feedId else { return nil } 33 | 34 | let request = Feed.fetchRequest() 35 | request.predicate = NSPredicate(format: "id == %@", feedID as CVarArg) 36 | request.fetchLimit = 1 37 | 38 | return try? self.managedObjectContext?.fetch(request).first 39 | } 40 | 41 | var itemsArray: [Item] { 42 | items?.sortedArray(using: [ 43 | NSSortDescriptor(key: "published", ascending: false), 44 | NSSortDescriptor(key: "title", ascending: true) 45 | ]) as? [Item] ?? [] 46 | } 47 | 48 | static func create(in managedObjectContext: NSManagedObjectContext, feedId: UUID) -> FeedData { 49 | let newFeed = self.init(context: managedObjectContext) 50 | newFeed.id = UUID() 51 | newFeed.feedId = feedId 52 | 53 | return newFeed 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Den/Utilities/CrashUtility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CrashUtility.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/12/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import OSLog 11 | import SwiftUI 12 | 13 | struct CrashUtility { 14 | static func handleCriticalError(_ anError: NSError) { 15 | let formattedMessage = self.formatErrorMessage(anError) 16 | Logger.main.critical("\(formattedMessage)") 17 | 18 | DispatchQueue.main.async { 19 | NotificationCenter.default.post( 20 | name: .appErrored, 21 | object: nil, 22 | userInfo: ["message": formattedMessage as Any] 23 | ) 24 | } 25 | } 26 | 27 | static func formatErrorMessage(_ anError: NSError?) -> String { 28 | guard let anError = anError else { return "Unknown error" } 29 | 30 | guard anError.domain.compare("NSCocoaErrorDomain") == .orderedSame else { 31 | return "Application error: \(anError)" 32 | } 33 | 34 | let messages: String = "Unrecoverable data error. \(anError.localizedDescription)" 35 | var errors = [AnyObject]() 36 | 37 | if anError.code == NSValidationMultipleErrorsError { 38 | if let multipleErros = anError.userInfo[NSDetailedErrorsKey] as? [AnyObject] { 39 | errors = multipleErros 40 | } 41 | } else { 42 | errors = [AnyObject]() 43 | errors.append(anError) 44 | } 45 | 46 | if errors.count == 0 { 47 | return "" 48 | } 49 | 50 | return messages 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /WidgetExtension/LatestItems/LatestItemsWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestItemsWidget.swift 3 | // LatestItemsWidget 4 | // 5 | // Created by Garrett Johnson on 4/28/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | import SwiftUI 11 | 12 | import SDWebImage 13 | import SDWebImageSVGCoder 14 | import SDWebImageWebPCoder 15 | 16 | struct LatestItemsWidget: Widget { 17 | let kind: String = "LatestItemsWidget" 18 | 19 | var body: some WidgetConfiguration { 20 | AppIntentConfiguration( 21 | kind: kind, 22 | intent: LatestItemsConfigurationIntent.self, 23 | provider: LatestItemsProvider() 24 | ) { entry in 25 | LatestItemsView(entry: entry) 26 | .containerBackground(.background, for: .widget) 27 | .defaultAppStorage(.group) 28 | } 29 | .configurationDisplayName( 30 | Text("Latest Items", comment: "Widget display name.") 31 | ) 32 | .supportedFamilies([ 33 | .systemSmall, 34 | .systemMedium, 35 | .systemLarge, 36 | .systemExtraLarge 37 | ]) 38 | } 39 | } 40 | 41 | #Preview(as: .systemMedium) { 42 | LatestItemsWidget() 43 | } timeline: { 44 | LatestItemsEntry( 45 | date: .now, 46 | items: [], 47 | sourceID: nil, 48 | sourceType: nil, 49 | unread: 10, 50 | title: Text("Inbox", comment: "Widget title."), 51 | faviconURL: nil, 52 | faviconImage: nil, 53 | symbol: nil, 54 | configuration: .init(source: SourceQuery.defaultSource) 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/SidebarStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarStatus.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/3/22. 6 | // Copyright © 2022 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SidebarStatus: View { 12 | @EnvironmentObject private var networkMonitor: NetworkMonitor 13 | @EnvironmentObject private var refreshManager: RefreshManager 14 | 15 | let feedCount: Int 16 | 17 | @AppStorage("Refreshed") private var refreshedTimestamp: Double? 18 | 19 | var body: some View { 20 | VStack { 21 | if !networkMonitor.isConnected { 22 | Text("Network Offline", comment: "Status message.").foregroundStyle(.secondary) 23 | } else if feedCount != 0 { 24 | if refreshManager.refreshing { 25 | ProgressView(refreshManager.progress) 26 | .progressViewStyle(RefreshProgressViewStyle(feedCount: feedCount)) 27 | } else if let timestamp = refreshedTimestamp { 28 | RelativeRefreshedDate(timestamp: timestamp) 29 | } else { 30 | #if os(macOS) 31 | Text( 32 | "Press \(Image(systemName: "command")) R to Refresh", 33 | comment: "Status message." 34 | ) 35 | .imageScale(.small) 36 | #else 37 | Text("Pull to Refresh", comment: "Status message.") 38 | #endif 39 | } 40 | } 41 | } 42 | .lineLimit(2) 43 | .font(.caption) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Scripts/SaveTestData.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # SaveTestData.sh 4 | # Den 5 | # 6 | # Created by Garrett Johnson on 7/29/23. 7 | # Copyright © 2023 Garrett Johnson. All rights reserved. 8 | # 9 | # Creates test fixture data from current installation databases and preferences. 10 | # Script should be run from project root with `Scripts/SaveTestData.sh`. 11 | # iCloud MUST be disabled for Den in macOS settings. 12 | # 13 | 14 | set -e 15 | 16 | # Group Container 17 | 18 | sourceGroupDirectory="$HOME/Library/Group Containers/group.net.devsci.den" 19 | sourceGroupAppSupportDirectory="$sourceGroupDirectory/Library/Application Support" 20 | destinationGroupAppSupportDirectory="./TestData/GroupContainer/Library/Application Support" 21 | 22 | rm -rf "$destinationGroupAppSupportDirectory" 23 | mkdir -p "$destinationGroupAppSupportDirectory" 24 | 25 | cp "$sourceGroupAppSupportDirectory/Den.sqlite" "$destinationGroupAppSupportDirectory/" 26 | cp "$sourceGroupAppSupportDirectory/Den.sqlite-wal" "$destinationGroupAppSupportDirectory/" 27 | cp "$sourceGroupAppSupportDirectory/Den-Local.sqlite" "$destinationGroupAppSupportDirectory/" 28 | cp "$sourceGroupAppSupportDirectory/Den-Local.sqlite-wal" "$destinationGroupAppSupportDirectory/" 29 | 30 | # App Container 31 | 32 | sourceAppPreferencesDirectory="$HOME/Library/Containers/net.devsci.den/Data/Library/Preferences" 33 | sourceAppPreferences="$sourceAppPreferencesDirectory/net.devsci.den.plist" 34 | destinationAppPreferencesDirectory="./TestData/AppContainer/Library/Preferences" 35 | 36 | rm -rf "$destinationAppPreferencesDirectory" 37 | mkdir -p "$destinationAppPreferencesDirectory" 38 | 39 | cp $sourceAppPreferences "$destinationAppPreferencesDirectory/" 40 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/MacSidebarBottomBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MacSidebarBottomBar.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/22/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MacSidebarBottomBar: View { 12 | @Environment(\.displayScale) private var displayScale 13 | 14 | @EnvironmentObject private var refreshManager: RefreshManager 15 | 16 | let feedCount: Int 17 | 18 | var body: some View { 19 | VStack(spacing: 0) { 20 | Rectangle().fill(BackgroundSeparatorShapeStyle()).frame(height: 1) 21 | HStack { 22 | VStack(alignment: .leading) { 23 | SidebarStatus(feedCount: feedCount) 24 | } 25 | 26 | Spacer() 27 | 28 | if refreshManager.refreshing { 29 | ProgressView(refreshManager.progress) 30 | .progressViewStyle(.circular) 31 | .labelsHidden() 32 | .scaleEffect(1 / displayScale) 33 | .frame(width: 18) 34 | .offset(y: 1) 35 | } else { 36 | RefreshButton() 37 | .labelStyle(.iconOnly) 38 | .imageScale(.large) 39 | .fontWeight(.medium) 40 | .buttonStyle(.plain) 41 | .foregroundStyle(.secondary) 42 | .disabled(feedCount == 0) 43 | } 44 | } 45 | .padding(12) 46 | .frame(height: 48) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Den/Views/Item/ToggleBookmarkedButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToggleBookmarkedButton.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 7/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ToggleBookmarkedButton: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | 14 | @ObservedObject var item: Item 15 | 16 | var body: some View { 17 | Button { 18 | withAnimation { 19 | if item.bookmarked { 20 | item.bookmarks.forEach { viewContext.delete($0) } 21 | item.bookmarked = false 22 | } else { 23 | _ = Bookmark.create(in: viewContext, item: item) 24 | item.bookmarked = true 25 | } 26 | 27 | do { 28 | try viewContext.save() 29 | } catch { 30 | CrashUtility.handleCriticalError(error as NSError) 31 | } 32 | } 33 | } label: { 34 | Label { 35 | if item.bookmarked { 36 | Text("Unbookmark", comment: "Button label.") 37 | } else { 38 | Text("Bookmark", comment: "Button label.") 39 | } 40 | } icon: { 41 | Image(systemName: "bookmark").symbolVariant(item.bookmarked ? .slash : .none) 42 | } 43 | } 44 | .contentTransition(.symbolEffect(.replace)) 45 | .accessibilityIdentifier("ToggleBookmarked") 46 | .help(Text("Bookmark/unbookmark", comment: "Button help text.")) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Den/Views/Page/FeedItemGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedItemGroup.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/28/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedItemGroup: View { 12 | @ObservedObject var feed: Feed 13 | 14 | let hideRead: Bool 15 | let items: [Item] 16 | let filteredItems: [Item] 17 | 18 | init(feed: Feed, hideRead: Bool, items: [Item]) { 19 | self.feed = feed 20 | self.hideRead = hideRead 21 | self.items = items 22 | self.filteredItems = items.visibilityFiltered(hideRead ? false : nil) 23 | } 24 | 25 | var body: some View { 26 | Section { 27 | if feed.feedData == nil || feed.feedData?.error != nil { 28 | FeedUnavailable(feed: feed) 29 | } else if items.isEmpty { 30 | FeedEmpty() 31 | } else if items.unread.isEmpty && hideRead { 32 | AllRead() 33 | } else { 34 | ForEach(filteredItems) { item in 35 | ItemActionView(item: item, isLastInList: item == filteredItems.last) { 36 | if feed.wrappedPreviewStyle == .expanded { 37 | ItemPreviewExpanded(item: item, feed: feed) 38 | } else { 39 | ItemPreviewCompressed(item: item, feed: feed) 40 | } 41 | } 42 | } 43 | } 44 | } header: { 45 | FeedNavLink(feed: feed).buttonStyle(FeedTitleButtonStyle()) 46 | } footer: { 47 | Spacer().frame(height: 8) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Den/Views/Page/GroupedLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupedLayout.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 3/15/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GroupedLayout: View { 12 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize 13 | @Environment(\.hideRead) private var hideRead 14 | 15 | @ObservedObject var page: Page 16 | 17 | @ScaledMetric private var idealColumnWidth = Columnizer.idealColumnWidth 18 | 19 | let items: FetchedResults 20 | 21 | var body: some View { 22 | GeometryReader { geometry in 23 | ScrollView(.vertical) { 24 | HStack(alignment: .top) { 25 | ForEach( 26 | Columnizer.columnize( 27 | columnCount: Int(geometry.size.width / idealColumnWidth), 28 | list: page.feedsArray 29 | ), 30 | id: \.0 31 | ) { _, feeds in 32 | LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { 33 | ForEach(feeds) { feed in 34 | FeedItemGroup( 35 | feed: feed, 36 | hideRead: hideRead, 37 | items: items.forFeed(feed) 38 | ) 39 | } 40 | } 41 | } 42 | } 43 | .padding([.horizontal, .top]) 44 | .padding(.bottom, 8) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /WebExtension/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "CFBundleDisplayName" : { 5 | "comment" : "Bundle display name", 6 | "extractionState" : "extracted_with_value", 7 | "localizations" : { 8 | "en" : { 9 | "stringUnit" : { 10 | "state" : "new", 11 | "value" : "Den Web Extension" 12 | } 13 | }, 14 | "en-AU" : { 15 | "stringUnit" : { 16 | "state" : "translated", 17 | "value" : "Den Web Extension" 18 | } 19 | } 20 | } 21 | }, 22 | "CFBundleName" : { 23 | "comment" : "Bundle name", 24 | "extractionState" : "extracted_with_value", 25 | "localizations" : { 26 | "en" : { 27 | "stringUnit" : { 28 | "state" : "new", 29 | "value" : "Den Web Extension" 30 | } 31 | }, 32 | "en-AU" : { 33 | "stringUnit" : { 34 | "state" : "translated", 35 | "value" : "Den Web Extension" 36 | } 37 | } 38 | } 39 | }, 40 | "NSHumanReadableCopyright" : { 41 | "comment" : "Copyright (human-readable)", 42 | "extractionState" : "extracted_with_value", 43 | "localizations" : { 44 | "en" : { 45 | "stringUnit" : { 46 | "state" : "new", 47 | "value" : "Copyright © 2020-2024 Garrett Johnson" 48 | } 49 | }, 50 | "en-AU" : { 51 | "stringUnit" : { 52 | "state" : "translated", 53 | "value" : "Copyright © 2020-2024 Garrett Johnson" 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | "version" : "1.0" 60 | } -------------------------------------------------------------------------------- /Den/UI/DropDelegates/BookmarksNavDropDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarksNavDropDelegate.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 9/16/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import SwiftUI 11 | import UniformTypeIdentifiers 12 | 13 | struct BookmarksNavDropDelegate: DropDelegate { 14 | let context: NSManagedObjectContext 15 | 16 | func performDrop(info: DropInfo) -> Bool { 17 | guard info.hasItemsConforming(to: [.denItem]) else { 18 | return false 19 | } 20 | 21 | for provider in info.itemProviders(for: [.denItem]) { 22 | handleNewBookmark(provider) 23 | } 24 | 25 | return true 26 | } 27 | 28 | private func handleNewBookmark(_ provider: NSItemProvider) { 29 | _ = provider.loadTransferable(type: TransferableItem.self) { result in 30 | guard case .success(let transferableItem) = result else { return } 31 | 32 | Task { 33 | await createBookmark(transferableItem.objectURI) 34 | } 35 | } 36 | } 37 | 38 | private func createBookmark(_ itemObjectURI: URL) { 39 | guard 40 | let objectID = context.persistentStoreCoordinator?.managedObjectID( 41 | forURIRepresentation: itemObjectURI 42 | ), 43 | let item = try? context.existingObject(with: objectID) as? Item 44 | else { return } 45 | 46 | _ = Bookmark.create(in: context, item: item) 47 | item.bookmarked = true 48 | 49 | do { 50 | try context.save() 51 | } catch { 52 | CrashUtility.handleCriticalError(error as NSError) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Den/Views/Sidebar/SidebarFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarFeed.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/6/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SidebarFeed: View { 12 | @Environment(\.managedObjectContext) private var viewContext 13 | @Environment(\.showUnreadCounts) private var showUnreadCounts 14 | 15 | @ObservedObject var feed: Feed 16 | 17 | var unreadCount: Int 18 | 19 | @FocusState private var titleFieldFocused: Bool 20 | 21 | var body: some View { 22 | Label { 23 | #if os(macOS) 24 | TextField(text: $feed.wrappedTitle) { 25 | feed.displayTitle 26 | } 27 | .focused($titleFieldFocused) 28 | .onChange(of: titleFieldFocused) { _, isFocused in 29 | if !isFocused && viewContext.hasChanges { 30 | do { 31 | try viewContext.save() 32 | } catch { 33 | CrashUtility.handleCriticalError(error as NSError) 34 | } 35 | } 36 | } 37 | #else 38 | feed.displayTitle 39 | #endif 40 | } icon: { 41 | Favicon(url: feed.feedData?.favicon) { 42 | FeedFaviconPlaceholder() 43 | } 44 | } 45 | .badge(showUnreadCounts ? unreadCount : 0) 46 | .tag(DetailPanel.feed(feed.objectID.uriRepresentation())) 47 | .modifier(DraggableFeedModifier(feed: feed)) 48 | .contextMenu { 49 | DeleteFeedButton(feed: feed) 50 | } 51 | .accessibilityIdentifier("SidebarFeed") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Den/Views/Settings/SettingsSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsSheet.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 10/15/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SettingsSheet: View { 12 | @Environment(\.dismiss) private var dismiss 13 | 14 | var body: some View { 15 | NavigationStack { 16 | Form { 17 | GeneralSection() 18 | AppearanceSection() 19 | BlocklistsSection() 20 | ResetSection() 21 | #if os(iOS) 22 | AboutSection() 23 | #endif 24 | 25 | NavigationLink { 26 | AdvancedSection() 27 | } label: { 28 | Label { 29 | Text("Advanced", comment: "Button label.") 30 | } icon: { 31 | Image(systemName: "gearshape.2") 32 | } 33 | } 34 | } 35 | .formStyle(.grouped) 36 | .buttonStyle(.borderless) 37 | .navigationTitle(Text("Settings", comment: "Navigation title.")) 38 | #if os(iOS) 39 | .toolbar { 40 | ToolbarItem { 41 | Button { 42 | dismiss() 43 | } label: { 44 | Label { 45 | Text("Close", comment: "Button label.") 46 | } icon: { 47 | Image(systemName: "xmark") 48 | } 49 | } 50 | } 51 | } 52 | #endif 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Den/Views/Feed/FeedLayoutSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedLayoutSection.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 1/11/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedLayoutSection: View { 12 | @Environment(\.hideRead) private var hideRead 13 | 14 | @ObservedObject var feed: Feed 15 | 16 | let geometry: GeometryProxy 17 | let items: [Item] 18 | 19 | @ViewBuilder var header: Header 20 | 21 | var body: some View { 22 | Section { 23 | if items.unread.isEmpty && hideRead { 24 | AllRead(largeDisplay: true) 25 | } else { 26 | BoardView( 27 | width: geometry.size.width, 28 | list: items.visibilityFiltered(hideRead ? false : nil) 29 | ) { item in 30 | ItemActionView(item: item, isStandalone: true, showGoToFeed: false) { 31 | if feed.wrappedPreviewStyle == .expanded { 32 | ItemPreviewExpanded(item: item, feed: feed) 33 | } else { 34 | ItemPreviewCompressed(item: item, feed: feed) 35 | } 36 | } 37 | } 38 | .modifier(SafeAreaModifier(geometry: geometry)) 39 | } 40 | } header: { 41 | HStack { 42 | header.font(.title3) 43 | Spacer() 44 | } 45 | .modifier(SafeAreaModifier(geometry: geometry)) 46 | .padding(.horizontal) 47 | .padding(.vertical, 12) 48 | .background(.fill.quaternary) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Den/Views/Page/DeckColumn.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeckColumn.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/18/20. 6 | // Copyright © 2020 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DeckColumn: View { 12 | @ObservedObject var feed: Feed 13 | 14 | let hideRead: Bool 15 | let items: [Item] 16 | let filteredItems: [Item] 17 | 18 | init(feed: Feed, hideRead: Bool, items: [Item]) { 19 | self.feed = feed 20 | self.hideRead = hideRead 21 | self.items = items 22 | self.filteredItems = items.visibilityFiltered(hideRead ? false : nil) 23 | } 24 | 25 | var body: some View { 26 | LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { 27 | Section { 28 | if feed.feedData == nil || feed.feedData?.error != nil { 29 | FeedUnavailable(feed: feed) 30 | } else if items.isEmpty { 31 | FeedEmpty() 32 | } else if items.unread.isEmpty && hideRead { 33 | AllRead() 34 | } else { 35 | ForEach(filteredItems) { item in 36 | ItemActionView(item: item, isLastInList: filteredItems.last == item) { 37 | if feed.wrappedPreviewStyle.rawValue == 1 { 38 | ItemPreviewExpanded(item: item, feed: feed) 39 | } else { 40 | ItemPreviewCompressed(item: item, feed: feed) 41 | } 42 | } 43 | } 44 | } 45 | } header: { 46 | FeedNavLink(feed: feed).buttonStyle(FeedTitleButtonStyle()) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /UITests/ItemUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemUITests.swift 3 | // UITests 4 | // 5 | // Created by Garrett Johnson on 7/13/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class ItemUITests: UITestCase { 12 | @MainActor 13 | func testItemView() throws { 14 | let app = launchApp(inMemory: false) 15 | 16 | app.staticTexts.matching(identifier: "SidebarPage").element(boundBy: 3).tap() 17 | 18 | hideSidebar(app) 19 | 20 | #if os(macOS) 21 | app.radioButtons["GroupedLayout"].tap() 22 | #else 23 | app.buttons["PageLayoutPicker"].tap() 24 | app.buttons["GroupedLayout"].tap() 25 | #endif 26 | 27 | app.buttons["ItemAction"].firstMatch.tap() 28 | 29 | sleep(5) 30 | 31 | attachScreenshot(of: app.windows.firstMatch, named: "item-browser-view") 32 | } 33 | 34 | @MainActor 35 | func testItemReader() throws { 36 | let app = launchApp(inMemory: false) 37 | 38 | app.staticTexts.matching(identifier: "SidebarPage").element(boundBy: 3).tap() 39 | 40 | hideSidebar(app) 41 | 42 | #if os(macOS) 43 | app.radioButtons["GroupedLayout"].tap() 44 | #else 45 | app.buttons["PageLayoutPicker"].tap() 46 | app.buttons["GroupedLayout"].tap() 47 | #endif 48 | 49 | app.buttons["ItemAction"].firstMatch.tap() 50 | sleep(4) 51 | 52 | #if os(macOS) 53 | app.popUpButtons["BrowserViewMenu"].buttons.firstMatch.tap() 54 | #else 55 | app.buttons["BrowserViewMenu"].tap() 56 | #endif 57 | sleep(5) 58 | 59 | attachScreenshot(of: app.windows.firstMatch, named: "item-reader-view") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Den/Data/Tasks/MaintenanceTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MaintenanceTask.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 5/14/24. 6 | // Copyright © 2024 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import OSLog 11 | 12 | struct MaintenanceTask { 13 | static func execute() async { 14 | let context = DataController.shared.container.newBackgroundContext() 15 | context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType) 16 | 17 | context.performAndWait { 18 | trimHistory(context: context) 19 | trimSearches(context: context) 20 | 21 | do { 22 | try context.save() 23 | UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "Maintained") 24 | Logger.main.info("Maintenance operations completed") 25 | } catch { 26 | Logger.main.error("Saving maintenance task context failed with error: \(error)") 27 | } 28 | } 29 | 30 | await BlocklistManager.cleanupContentRulesLists() 31 | await BlocklistManager.refreshAllContentRulesLists() 32 | } 33 | 34 | private static func trimHistory(context: NSManagedObjectContext) { 35 | DataController.truncate( 36 | History.self, 37 | context: context, 38 | sortDescriptors: [NSSortDescriptor(keyPath: \History.visited, ascending: false)], 39 | offset: 100000 40 | ) 41 | } 42 | 43 | private static func trimSearches(context: NSManagedObjectContext) { 44 | DataController.truncate( 45 | Search.self, 46 | context: context, 47 | sortDescriptors: [NSSortDescriptor(keyPath: \Search.submitted, ascending: false)], 48 | offset: 20 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Den/UI/Styles/FeedTitleButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedTitleButtonStyle.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 11/12/21. 6 | // Copyright © 2021 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedTitleButtonStyle: ButtonStyle { 12 | @Environment(\.colorScheme) private var colorScheme 13 | 14 | func makeBody(configuration: ButtonStyle.Configuration) -> some View { 15 | #if os(macOS) 16 | if colorScheme == .dark { 17 | configuration.label 18 | .font(.title3) 19 | .padding(12) 20 | .background(.fill.quaternary) 21 | .overlay { 22 | clipShape.strokeBorder(.separator) 23 | } 24 | .clipShape(clipShape) 25 | .background(.background) 26 | } else { 27 | configuration.label 28 | .font(.title3) 29 | .padding(12) 30 | .padding(.bottom, 1) 31 | .overlay(Divider(), alignment: .bottom) 32 | .background(.background) 33 | .clipShape(clipShape) 34 | .background(.windowBackground) 35 | } 36 | #else 37 | configuration.label 38 | .font(.title3) 39 | .padding(12) 40 | .padding(.bottom, 1) 41 | .overlay(Divider(), alignment: .bottom) 42 | .background(Color(.secondarySystemGroupedBackground)) 43 | .clipShape(clipShape) 44 | .background(Color(.systemGroupedBackground)) 45 | #endif 46 | } 47 | 48 | private var clipShape: some InsettableShape { 49 | UnevenRoundedRectangle( 50 | cornerRadii: .init(topLeading: 8, bottomLeading: 0, bottomTrailing: 0, topTrailing: 8) 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Den/Views/Settings/BlocklistStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlocklistStatus.swift 3 | // Den 4 | // 5 | // Created by Garrett Johnson on 12/29/23. 6 | // Copyright © 2023 Garrett Johnson. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BlocklistStatusView: View { 12 | @ObservedObject var blocklistStatus: BlocklistStatus 13 | 14 | var body: some View { 15 | Section { 16 | LabeledContent { 17 | VStack(alignment: .trailing) { 18 | if let refreshed = blocklistStatus.refreshed { 19 | Text(refreshed.formatted()) 20 | } 21 | } 22 | } label: { 23 | Text("Updated", comment: "Blocklist status row label.") 24 | } 25 | 26 | LabeledContent { 27 | if blocklistStatus.httpCode == 0 { 28 | Text("Unavailable", comment: "Blocklist response code placeholder.") 29 | } else { 30 | Text(verbatim: "\(blocklistStatus.httpCode)") 31 | } 32 | } label: { 33 | Text("Response Code", comment: "Blocklist status row label.") 34 | } 35 | 36 | LabeledContent { 37 | if blocklistStatus.compiledSuccessfully { 38 | Text("Yes", comment: "Blocklist compile/load status message.") 39 | } else { 40 | Text("No", comment: "Blocklist compile/load status message.") 41 | .foregroundStyle(.orange) 42 | } 43 | } label: { 44 | Text("Rules Applied", comment: "Blocklist status row label.") 45 | } 46 | } header: { 47 | Text("Status", comment: "Blocklist status section header.") 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /TestPlans/MacComprehensive.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "3F43133D-0186-4100-862B-724AA885DD72", 5 | "name" : "en_US-Dark", 6 | "options" : { 7 | "commandLineArgumentEntries" : [ 8 | { 9 | "argument" : "-dark-appearance" 10 | } 11 | ], 12 | "language" : "en", 13 | "region" : "US" 14 | } 15 | }, 16 | { 17 | "id" : "F15C1CBE-4231-497D-AE8D-26B115DD981B", 18 | "name" : "en_US-Light", 19 | "options" : { 20 | "commandLineArgumentEntries" : [ 21 | { 22 | "argument" : "-light-appearance" 23 | } 24 | ], 25 | "language" : "en", 26 | "region" : "US" 27 | } 28 | }, 29 | { 30 | "id" : "6AD2E6EC-F6C9-435D-8BC8-F458E478619C", 31 | "name" : "en_GB-Dark", 32 | "options" : { 33 | "commandLineArgumentEntries" : [ 34 | { 35 | "argument" : "-dark-appearance" 36 | } 37 | ], 38 | "language" : "en", 39 | "region" : "GB" 40 | } 41 | }, 42 | { 43 | "id" : "BF7D64C5-DA23-4946-8297-C002FCB41BB8", 44 | "name" : "en_GB-Light", 45 | "options" : { 46 | "commandLineArgumentEntries" : [ 47 | { 48 | "argument" : "-light-appearance" 49 | } 50 | ], 51 | "language" : "en", 52 | "region" : "GB" 53 | } 54 | } 55 | ], 56 | "defaultOptions" : { 57 | "codeCoverage" : false, 58 | "commandLineArgumentEntries" : [ 59 | 60 | ], 61 | "testTimeoutsEnabled" : true 62 | }, 63 | "testTargets" : [ 64 | { 65 | "target" : { 66 | "containerPath" : "container:Den.xcodeproj", 67 | "identifier" : "68E83BF82A6004590014DC64", 68 | "name" : "UI Tests" 69 | } 70 | } 71 | ], 72 | "version" : 1 73 | } 74 | --------------------------------------------------------------------------------