├── 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 |
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 |
--------------------------------------------------------------------------------