diff --git a/Icon.svg b/Icon.svg new file mode 100644 index 0000000..d16a646 --- /dev/null +++ b/Icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png index 4cf8943..52765c5 100644 Binary files a/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 4dd69b2..ac2c8ca 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -35,10 +35,10 @@ struct RootView: View { systemOpenURL(url) } } - // Route inline comment/text links the same way. + // Route inline comment/text links the same way, honoring reader mode. .environment(\.openURL, OpenURLAction { url in if settings.openLinksInApp { - linkOpener.present(url, reader: false) + linkOpener.present(url, reader: settings.readerMode) return .handled } return .systemAction diff --git a/Sources/Features/Feed/FeedView.swift b/Sources/Features/Feed/FeedView.swift index 66635e6..e3df2bf 100644 --- a/Sources/Features/Feed/FeedView.swift +++ b/Sources/Features/Feed/FeedView.swift @@ -1,9 +1,46 @@ import SwiftUI +import UIKit + +/// Scroll position plus the current top content inset, observed together so the +/// nav-bar height can be derived from the inset. +private struct ScrollInfo: Equatable { + var scrolled: CGFloat + var insetTop: CGFloat +} /// The primary feeds screen: a pinned feed selector over a paginated story list. +/// The Ember wordmark fades + lifts and the selector eases up as you scroll; the +/// nav bar collapses to reclaim the wordmark's space once it's gone. struct FeedView: View { @State private var vm = FeedViewModel() @State private var path = NavigationPath() + @State private var logoHidden = false + @State private var logoOpacity: CGFloat = 1 + @State private var logoOffset: CGFloat = 0 + @State private var pickerOffset: CGFloat = 0 + /// Real nav-bar height, derived as `inset − statusBar − pickerHeight`, computed + /// before the first scroll so the list doesn't jump on first use. + @State private var navBar: CGFloat = 44 + @State private var pickerHeight: CGFloat = 0 + @State private var shownInsetTop: CGFloat = 0 + + /// Picker rises at half the wordmark's rate → over 2× the scroll. + private static let pickerRate: CGFloat = 0.5 + + /// Status-bar inset from the key window (the nav bar isn't part of this). + private var statusBarTop: CGFloat { + (UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .safeAreaInsets.top) ?? 0 + } + + private func updateNavBar() { + guard shownInsetTop > 0, pickerHeight > 0 else { return } + let measured = shownInsetTop - statusBarTop - pickerHeight + if measured > 1 { navBar = measured } + } @Environment(SettingsStore.self) private var settings @Environment(BookmarkStore.self) private var bookmarks @@ -13,6 +50,10 @@ struct FeedView: View { content .navigationTitle("Ember") .navigationBarTitleDisplayMode(.inline) + .toolbar(logoHidden ? .hidden : .visible, for: .navigationBar) + // One continuous bar: the picker's `.bar` extends up behind the + // wordmark, so there's no second bar to fade or seam. + .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .principal) { HStack(spacing: 6) { @@ -23,6 +64,8 @@ struct FeedView: View { .font(.system(.headline, design: .rounded).weight(.bold)) .foregroundStyle(Theme.textPrimary) } + .opacity(logoOpacity) + .offset(y: logoOffset) .accessibilityAddTraits(.isHeader) .accessibilityLabel("Ember") } @@ -32,6 +75,8 @@ struct FeedView: View { Haptics.selection() Task { await vm.switchTo(feed) } } + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { pickerHeight = $0; updateNavBar() } + .offset(y: pickerOffset) } .navigationDestination(for: HNItem.self) { StoryDetailView(item: $0) } .navigationDestination(for: UserRoute.self) { UserView(username: $0.username) } @@ -68,7 +113,8 @@ struct FeedView: View { ZStack { // Hide the default disclosure chevron for a cleaner row. NavigationLink(value: story) { EmptyView() }.opacity(0) - StoryRow(item: story, rank: index + 1) + StoryRow(item: story, rank: index + 1, + onSelectUser: { path.append(UserRoute(username: $0)) }) } .listRowInsets(EdgeInsets(top: 0, leading: Spacing.l, bottom: 0, trailing: Spacing.l)) .listRowSeparatorTint(Theme.separator) @@ -102,6 +148,34 @@ struct FeedView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .background(Theme.background) + .onScrollGeometryChange(for: ScrollInfo.self) { geo in + ScrollInfo(scrolled: geo.contentOffset.y + geo.contentInsets.top, + insetTop: geo.contentInsets.top) + } action: { _, info in + let scrolled = info.scrolled + if !logoHidden { + shownInsetTop = info.insetTop + updateNavBar() + } + // Fade + lift the wordmark continuously; compensate for the inset jump + // while hidden so it tracks the true visual position across the toggle. + let visualScroll = scrolled + (logoHidden ? navBar : 0) + let progress = max(0, min(1, visualScroll / navBar)) + logoOpacity = max(0.001, 1 - progress) + logoOffset = -progress * navBar + // Picker eases up at half rate; offset compensated once hidden so it + // stays put across the (instant) collapse. + let pickerRise = max(0, min(navBar, visualScroll * Self.pickerRate)) + pickerOffset = logoHidden ? (navBar - pickerRise) : -pickerRise + // Collapse instantly. Un-collapse a bar-height from the top so the + // offset never goes positive (no gap above the picker); the wide + // hide/show gap prevents flapping. + if !logoHidden, scrolled > navBar * 2.2 { + logoHidden = true + } else if logoHidden, scrolled < navBar { + logoHidden = false + } + } .refreshable { await vm.reload() } } } diff --git a/Sources/Features/Onboarding/OnboardingView.swift b/Sources/Features/Onboarding/OnboardingView.swift index 74c6783..0838d28 100644 --- a/Sources/Features/Onboarding/OnboardingView.swift +++ b/Sources/Features/Onboarding/OnboardingView.swift @@ -260,6 +260,8 @@ struct OnboardingView: View { ToggleRow(title: "Rank numbers", subtitle: "Show numeric position in feeds", systemImage: "number", isOn: settings.showRankNumbers) Divider().padding(.leading, 40) ToggleRow(title: "Story thumbnails", subtitle: "Show the site favicon next to each story", systemImage: "square.fill.text.grid.1x2", isOn: settings.showThumbnails) + Divider().padding(.leading, 40) + ToggleRow(title: "URL above title", subtitle: "Show the site link above each headline, classic HN style", systemImage: "link", isOn: settings.urlAboveTitle) } .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: Radius.m, style: .continuous)) @@ -383,7 +385,7 @@ struct OnboardingView: View { .font(.system(size: 10, weight: .heavy)) .tracking(1) .foregroundStyle(Theme.textTertiary) - OnboardingPreviewRow(accent: settings.accent.color, showThumbnail: settings.showThumbnails, showRank: settings.showRankNumbers, underlineLinks: settings.underlineLinks) + OnboardingPreviewRow(accent: settings.accent.color, showThumbnail: settings.showThumbnails, showRank: settings.showRankNumbers, underlineLinks: settings.underlineLinks, urlAboveTitle: settings.urlAboveTitle) .padding(Spacing.m) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: Radius.m, style: .continuous)) @@ -608,16 +610,25 @@ private struct OnboardingPreviewRow: View { var showThumbnail: Bool = true var showRank: Bool = true var underlineLinks: Bool = true + var urlAboveTitle: Bool = false + + private var hostLabel: some View { + Text("paulgraham.com") + .font(AppFont.meta) + .foregroundStyle(Theme.textSecondary) + } var body: some View { HStack(alignment: .top, spacing: Spacing.m) { if showRank { + // Mirror StoryRow: when the URL sits above the title, shrink the + // rank and top-align it so they share the first line. Text("42") - .font(.system(.footnote, design: .rounded).weight(.bold)) + .font(urlAboveTitle ? AppFont.meta : .system(.footnote, design: .rounded).weight(.bold)) .monospacedDigit() .foregroundStyle(Theme.textTertiary) .frame(width: 22, alignment: .trailing) - .padding(.top, 2) + .padding(.top, urlAboveTitle ? 0 : 2) } if showThumbnail { RoundedRectangle(cornerRadius: 9, style: .continuous) @@ -626,19 +637,19 @@ private struct OnboardingPreviewRow: View { .overlay(Image(systemName: "flame.fill").foregroundStyle(accent)) } VStack(alignment: .leading, spacing: 5) { + if urlAboveTitle { hostLabel } Text("How to Do Great Work") .font(AppFont.storyTitle) .foregroundStyle(Theme.textPrimary) - Text("paulgraham.com") - .font(AppFont.meta) - .foregroundStyle(Theme.textSecondary) + if !urlAboveTitle { hostLabel } HStack(spacing: Spacing.m) { - StatLabel(systemImage: "arrow.up", value: "842", tint: accent) + StatLabel(systemImage: "arrow.up", value: "842", tint: Theme.upvote) StatLabel(systemImage: "bubble.left", value: "312") - Text("by pg · 1h") - .font(AppFont.meta) - .foregroundStyle(Theme.textSecondary) + Text("pg") + Text("1h") } + .font(AppFont.meta) + .foregroundStyle(Theme.textSecondary) Text("paulgraham.com/greatwork.html") .font(AppFont.meta) .foregroundStyle(accent) diff --git a/Sources/Features/Saved/SavedView.swift b/Sources/Features/Saved/SavedView.swift index 2f817de..3df0cdb 100644 --- a/Sources/Features/Saved/SavedView.swift +++ b/Sources/Features/Saved/SavedView.swift @@ -177,7 +177,8 @@ struct SavedView: View { @ViewBuilder private func row(_ story: HNItem, @ViewBuilder swipe: () -> some View) -> some View { ZStack { NavigationLink(value: story) { EmptyView() }.opacity(0) - StoryRow(item: story) + StoryRow(item: story, + onSelectUser: { path.append(UserRoute(username: $0)) }) } .listRowInsets(EdgeInsets(top: 0, leading: Spacing.l, bottom: 0, trailing: Spacing.l)) .listRowSeparatorTint(Theme.separator) diff --git a/Sources/Features/Search/SearchView.swift b/Sources/Features/Search/SearchView.swift index 5efd82f..6aaa4ee 100644 --- a/Sources/Features/Search/SearchView.swift +++ b/Sources/Features/Search/SearchView.swift @@ -54,7 +54,8 @@ struct SearchView: View { ForEach(vm.results) { story in ZStack { NavigationLink(value: story) { EmptyView() }.opacity(0) - StoryRow(item: story) + StoryRow(item: story, + onSelectUser: { path.append(UserRoute(username: $0)) }) } .listRowInsets(EdgeInsets(top: 0, leading: Spacing.l, bottom: 0, trailing: Spacing.l)) .listRowSeparatorTint(Theme.separator) diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 4b8efdc..8e302c7 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -125,7 +125,7 @@ struct SettingsView: View { Label("Open Links in App", systemImage: "safari") } Toggle(isOn: settings.readerMode) { - Label("Use Reader When Available", systemImage: "doc.plaintext") + Label("Open Links in Reader Mode", systemImage: "doc.plaintext") } .disabled(!settings.wrappedValue.openLinksInApp) Toggle(isOn: settings.markReadOnOpen) { @@ -134,6 +134,9 @@ struct SettingsView: View { Toggle(isOn: settings.showThumbnails) { Label("Show Story Thumbnails", systemImage: "square.fill.text.grid.1x2") } + Toggle(isOn: settings.urlAboveTitle) { + Label("Show URL Above Title", systemImage: "link") + } } } diff --git a/Sources/Features/Shared/StoryRow.swift b/Sources/Features/Shared/StoryRow.swift index 20e3337..ea7884e 100644 --- a/Sources/Features/Shared/StoryRow.swift +++ b/Sources/Features/Shared/StoryRow.swift @@ -6,16 +6,24 @@ import SwiftUI struct StoryRow: View { let item: HNItem var rank: Int? + /// When provided, the username becomes a tappable shortcut to the author's + /// profile. Passed by containers that own a navigation path; when nil the + /// username is plain text so the row's own tap (the story) stays intact. + var onSelectUser: ((String) -> Void)? @Environment(SettingsStore.self) private var settings @Environment(BookmarkStore.self) private var bookmarks @Environment(ReadStore.self) private var readStore @Environment(AccountStore.self) private var account @Environment(FavoritesStore.self) private var favorites + @Environment(VoteStore.self) private var voteStore @Environment(\.openArticle) private var openArticle @Environment(\.accessibilityDifferentiateWithoutColor) private var systemDiffNoColor @Environment(\.dynamicTypeSize) private var typeSize + /// Web fallback shown when a native upvote is rejected (expired session, etc.). + @State private var webTask: HNWebTask? + private var isRead: Bool { readStore.isRead(item.id) } /// When signed in, the save action manages HN favorites; otherwise local bookmarks. private var usesFavorites: Bool { settings.accountFeaturesEnabled && account.isSignedIn } @@ -32,6 +40,33 @@ struct StoryRow: View { } } + /// Whether the signed-in user can upvote this item directly from the row. + /// HN shows no vote arrow on your own posts, so hide the affordance there + /// rather than letting the tap fail into the web fallback. + private var canVote: Bool { + settings.accountFeaturesEnabled && account.isSignedIn + && item.kind != .job && item.author != account.username + } + private var hasVoted: Bool { voteStore.hasVoted(item.id) } + /// Points bumped by our own optimistic upvote (HN's API count lags). + private var displayedPoints: Int { item.points + (hasVoted ? 1 : 0) } + + /// Optimistic native upvote; on any failure, revert and offer the web fallback. + private func upvote() { + guard canVote, !hasVoted else { return } + voteStore.markVoted(item.id) + Haptics.soft() + Task { + do { + try await HNWebWriter(dataStore: account.dataStore).vote(itemID: item.id, up: true) + } catch { + voteStore.unmarkVoted(item.id) + Haptics.warning() + webTask = .item(itemID: item.id) + } + } + } + private var categoryTag: (String, Color)? { switch item.kind { case .job: return ("Job", Theme.upvote) @@ -46,12 +81,16 @@ struct StoryRow: View { var body: some View { HStack(alignment: .top, spacing: Spacing.m) { if settings.showRankNumbers, let rank { + // When the URL sits above the title, shrink the rank to the URL's + // size and top-align the two so they share the first line. Text("\(rank)") - .font(.system(.footnote, design: .rounded).weight(.bold)) + .font(settings.urlAboveTitle + ? AppFont.meta + : .system(.footnote, design: .rounded).weight(.bold)) .monospacedDigit() .foregroundStyle(Theme.textTertiary) .frame(width: 22, alignment: .trailing) - .padding(.top, 2) + .padding(.top, settings.urlAboveTitle ? 0 : 2) } if settings.showThumbnails { @@ -59,17 +98,18 @@ struct StoryRow: View { } VStack(alignment: .leading, spacing: 5) { + if settings.urlAboveTitle, let host = item.host { + hostLabel(host) + } + Text(item.displayTitle) .font(AppFont.storyTitle) .foregroundStyle(isRead ? Theme.textSecondary : Theme.textPrimary) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) - if let host = item.host { - Text(host) - .font(AppFont.meta) - .foregroundStyle(Theme.textSecondary) - .lineLimit(1) + if !settings.urlAboveTitle, let host = item.host { + hostLabel(host) } metaRow @@ -94,6 +134,9 @@ struct StoryRow: View { .accessibilityAddTraits(.isButton) .accessibilityActions { rowActions } .contextMenu { contextMenu } preview: { StoryPreview(item: item) } + .sheet(item: $webTask) { task in + HNWebSheet(task: task) + } } // MARK: Pieces @@ -126,22 +169,64 @@ struct StoryRow: View { .padding(.top, 1) } + /// Host as a tappable shortcut to the article when one exists. + @ViewBuilder private func hostLabel(_ host: String) -> some View { + let label = Text(host) + .font(AppFont.meta) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + if let url = item.articleURL { + Button { + Haptics.tap() + openArticle(url) + } label: { label } + .buttonStyle(.plain) + .accessibilityHidden(true) + } else { + label + } + } + private var metaRow: some View { HStack(spacing: Spacing.m) { if item.kind != .job { - StatLabel(systemImage: "arrow.up", value: "\(item.points)", tint: Theme.upvote) + upvoteStat StatLabel(systemImage: "bubble.left", value: "\(item.commentCount)") } if let tag = categoryTag, item.host != nil { TagBadge(text: tag.0, color: tag.1) } - HStack(spacing: 4) { + // Author and time-since are spaced like the other meta items (no + // separator bullet) so the whole row reads consistently. + // A plain Button (not a NavigationLink) keeps the username tappable + // without the List promoting it to the row's tap action — the rest of + // the row still opens the story. + if let onSelectUser { + Button { onSelectUser(item.author) } label: { + Text(item.author).lineLimit(1) + } + .buttonStyle(.plain) + } else { Text(item.author).lineLimit(1) - Text("·") - Text(RelativeTime.compact(item.date)) } - .font(AppFont.meta) - .foregroundStyle(Theme.textSecondary) + Text(RelativeTime.compact(item.date)) + } + .font(AppFont.meta) + .foregroundStyle(Theme.textSecondary) + } + + /// Points stat that doubles as a one-tap upvote when signed in. + @ViewBuilder private var upvoteStat: some View { + if canVote { + Button { upvote() } label: { + StatLabel(systemImage: hasVoted ? "arrow.up.circle.fill" : "arrow.up", + value: "\(displayedPoints)", tint: Theme.upvote) + } + .buttonStyle(.plain) + .disabled(hasVoted) + .accessibilityHidden(true) + } else { + StatLabel(systemImage: "arrow.up", value: "\(displayedPoints)", tint: Theme.upvote) } } @@ -151,6 +236,9 @@ struct StoryRow: View { if let url = item.articleURL { Button("Open Link") { openArticle(url) } } + if canVote, !hasVoted { + Button("Upvote") { upvote() } + } Button(saveActionTitle) { toggleSaved() Haptics.soft() diff --git a/Sources/Features/StoryDetail/CommentRow.swift b/Sources/Features/StoryDetail/CommentRow.swift index ee9f342..ba12f17 100644 --- a/Sources/Features/StoryDetail/CommentRow.swift +++ b/Sources/Features/StoryDetail/CommentRow.swift @@ -7,6 +7,9 @@ struct CommentRow: View { let opAuthor: String? let isCollapsed: Bool var canInteract: Bool = false + /// Whether the upvote button is offered — false for your own comments, which + /// HN won't let you vote on (no arrow), while reply/edit stay available. + var canVote: Bool = false var isVoted: Bool = false var canEdit: Bool = false var onReply: () -> Void = {} @@ -103,17 +106,19 @@ struct CommentRow: View { private var interactionBar: some View { HStack(spacing: Spacing.l) { - Button { - Haptics.soft() - onVote() - } label: { - Label(isVoted ? "Upvoted" : "Upvote", - systemImage: isVoted ? "arrow.up.circle.fill" : "arrow.up.circle") - .font(AppFont.metaStrong) - .foregroundStyle(isVoted ? Theme.upvote : Theme.textSecondary) + if canVote { + Button { + Haptics.soft() + onVote() + } label: { + Label(isVoted ? "Upvoted" : "Upvote", + systemImage: isVoted ? "arrow.up.circle.fill" : "arrow.up.circle") + .font(AppFont.metaStrong) + .foregroundStyle(isVoted ? Theme.upvote : Theme.textSecondary) + } + .buttonStyle(.plain) + .disabled(isVoted) } - .buttonStyle(.plain) - .disabled(isVoted) Button { Haptics.tap() diff --git a/Sources/Features/StoryDetail/StoryDetailView.swift b/Sources/Features/StoryDetail/StoryDetailView.swift index 36017f7..5760ac0 100644 --- a/Sources/Features/StoryDetail/StoryDetailView.swift +++ b/Sources/Features/StoryDetail/StoryDetailView.swift @@ -31,6 +31,11 @@ struct StoryDetailView: View { /// Whether logged-in write actions (vote / reply / comment) are available. private var canInteract: Bool { settings.accountFeaturesEnabled && account.isSignedIn } + /// Whether `author`'s item can be upvoted. You can't vote on your own posts — + /// HN renders no arrow, so we hide the affordance instead of failing into web. + private func canVote(_ author: String) -> Bool { + canInteract && author != account.username + } /// The author whose top-level threads should float to the top, if enabled. private var floatAuthor: String? { (canInteract && settings.myCommentsFirst) ? account.username : nil @@ -215,16 +220,18 @@ struct StoryDetailView: View { private var actionBar: some View { HStack(spacing: Spacing.m) { - Button { - upvote(story.id) - } label: { - Label(voteStore.hasVoted(story.id) ? "Upvoted" : "Upvote", - systemImage: voteStore.hasVoted(story.id) ? "arrow.up.circle.fill" : "arrow.up.circle") - .font(.subheadline.weight(.semibold)) + if canVote(story.author) { + Button { + upvote(story.id) + } label: { + Label(voteStore.hasVoted(story.id) ? "Upvoted" : "Upvote", + systemImage: voteStore.hasVoted(story.id) ? "arrow.up.circle.fill" : "arrow.up.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(Theme.upvote) + .disabled(voteStore.hasVoted(story.id)) } - .buttonStyle(.bordered) - .tint(Theme.upvote) - .disabled(voteStore.hasVoted(story.id)) Button { compose(parentID: story.id, title: "Add Comment", context: story.displayTitle) @@ -399,6 +406,7 @@ struct StoryDetailView: View { opAuthor: story.author, isCollapsed: vm.isCollapsed(comment.id), canInteract: canInteract, + canVote: canVote(comment.author), isVoted: voteStore.hasVoted(comment.id), canEdit: canEdit(comment), onReply: { compose(parentID: comment.id, title: "Reply", context: "Replying to \(comment.author)") }, diff --git a/Sources/Stores/SettingsStore.swift b/Sources/Stores/SettingsStore.swift index f0bf97b..9eee8cc 100644 --- a/Sources/Stores/SettingsStore.swift +++ b/Sources/Stores/SettingsStore.swift @@ -51,6 +51,8 @@ final class SettingsStore { var openLinksInApp: Bool { didSet { store(openLinksInApp, .openLinksInApp) } } + /// Open every in-app web link (story articles and inline comment/text links) + /// in Safari Reader when available. Requires `openLinksInApp`. var readerMode: Bool { didSet { store(readerMode, .readerMode) } } @@ -61,6 +63,11 @@ final class SettingsStore { var showThumbnails: Bool { didSet { store(showThumbnails, .showThumbnails) } } + /// Show the source URL/host above the title (aligned with the rank number) + /// rather than beneath it. Mirrors the classic HN site layout. + var urlAboveTitle: Bool { + didSet { store(urlAboveTitle, .urlAboveTitle) } + } /// Opt-in: enable signing in to a Hacker News account for voting, commenting /// and submitting. Default off; the app stays read-only and API-only when off. var accountFeaturesEnabled: Bool { @@ -123,6 +130,7 @@ final class SettingsStore { readerMode = defaults.object(forKey: Key.readerMode.rawValue) as? Bool ?? false markReadOnOpen = defaults.object(forKey: Key.markReadOnOpen.rawValue) as? Bool ?? true showThumbnails = defaults.object(forKey: Key.showThumbnails.rawValue) as? Bool ?? true + urlAboveTitle = defaults.object(forKey: Key.urlAboveTitle.rawValue) as? Bool ?? false accountFeaturesEnabled = defaults.object(forKey: Key.accountFeaturesEnabled.rawValue) as? Bool ?? false myCommentsFirst = defaults.object(forKey: Key.myCommentsFirst.rawValue) as? Bool ?? true commentSort = CommentSort(rawValue: defaults.string(forKey: Key.commentSort.rawValue) ?? "") ?? .ranked @@ -150,6 +158,7 @@ final class SettingsStore { distinguishWithoutColor = false showRankNumbers = true showThumbnails = true + urlAboveTitle = false readingTextScale = 1.0 hasCompletedOnboarding = false } @@ -162,6 +171,7 @@ final class SettingsStore { case readerMode = "settings.readerMode" case markReadOnOpen = "settings.markReadOnOpen" case showThumbnails = "settings.showThumbnails" + case urlAboveTitle = "settings.urlAboveTitle" case accountFeaturesEnabled = "settings.accountFeaturesEnabled" case myCommentsFirst = "settings.myCommentsFirst" case commentSort = "settings.commentSort"