Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ If you want to test it with me on testflight or think we can get it on the app s
<!-- USAGE EXAMPLES -->
## Usage

- Tap a `>>post` quote to preview it in a sheet. Follow quote chains inside the preview, then tap **Done** to return to your reading position.
- **Favorites → Thread Watcher** checks saved threads for unread replies and archived/unavailable status. Checks are foreground-only, throttled to once per 30 seconds with requests spaced apart; it does not send background notifications.
- **Settings → Filters & Highlights** saves literal keyword, poster ID, name, and tripcode rules for one board or all boards. Hide takes priority over highlight. Rules apply to catalog cards, thread posts, and quote previews, and stay on this device.
- **Settings → Appearance** offers System, Yotsuba, and Yotsuba B colors plus compact posts.
- An archived or unavailable thread offers **Find Next General** when you follow a general on that board. It searches for newer matching threads and keeps your saved name.
- **Post options → Reply on 4chan** starts a quoted draft. **Copy Draft & Open 4chan** opens the official website, where you paste the draft, attach media, complete CAPTCHA, and submit. Drafts remain local until you copy them; **Discard Draft** removes the local draft. This is a website handoff, not native posting through the read-only API.

Tap the clock on **Boards → Recent Threads** to search recently read threads and resume your saved position. History uses the existing **Remember Reading Progress** setting and is limited to 300 threads. Remove an entry or clear the list to erase its reading progress too.

Use a post’s **… → Save Reply** action to keep its text and exact link in **Favorites → Saved Replies**. Search saved text, open the original post, share its link, or swipe to remove it. Text stays available even if the original thread expires; images and videos are not saved with it.
Expand Down
24 changes: 24 additions & 0 deletions swiftchan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
objects = {

/* Begin PBXBuildFile section */
CA000006CCCC000100000002 /* ThreadWatcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000006CCCC000100000001 /* ThreadWatcherView.swift */; };
CA000005CCCC000100000002 /* ThreadWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000005CCCC000100000001 /* ThreadWatcher.swift */; };
CA000004CCCC000100000002 /* PostFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000004CCCC000100000001 /* PostFiltersView.swift */; };
CA000003CCCC000100000002 /* ChanPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000003CCCC000100000001 /* ChanPreferences.swift */; };
CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000002CCCC000100000001 /* ReplyDraftSheet.swift */; };
CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000001CCCC000100000001 /* QuotePreviewSheet.swift */; };
AC000001CCCC000100000002 /* RecentThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC000001CCCC000100000001 /* RecentThreadsView.swift */; };
AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000003AAAA000100000001 /* SavedReplyTests.swift */; };
AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000002AAAA000100000001 /* SavedRepliesView.swift */; };
Expand Down Expand Up @@ -155,6 +161,12 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
CA000006CCCC000100000001 /* ThreadWatcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadWatcherView.swift; sourceTree = "<group>"; };
CA000005CCCC000100000001 /* ThreadWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadWatcher.swift; sourceTree = "<group>"; };
CA000004CCCC000100000001 /* PostFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFiltersView.swift; sourceTree = "<group>"; };
CA000003CCCC000100000001 /* ChanPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChanPreferences.swift; sourceTree = "<group>"; };
CA000002CCCC000100000001 /* ReplyDraftSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyDraftSheet.swift; sourceTree = "<group>"; };
CA000001CCCC000100000001 /* QuotePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePreviewSheet.swift; sourceTree = "<group>"; };
AC000001CCCC000100000001 /* RecentThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentThreadsView.swift; sourceTree = "<group>"; };
AF000003AAAA000100000001 /* SavedReplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReplyTests.swift; sourceTree = "<group>"; };
AF000002AAAA000100000001 /* SavedRepliesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRepliesView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -311,6 +323,7 @@
E087391199AA472B45F68D1C /* RecurringFavoriteRow.swift */,
9CBC04E821A80DD362D9DDF2 /* RecurringMatchSheet.swift */,
BF000003EEEE000100000001 /* FavoritesBackupView.swift */,
CA000006CCCC000100000001 /* ThreadWatcherView.swift */,
9044AE15507088A1373984CA /* AddRecurringFavoriteSheet.swift */,
);
path = Favorites;
Expand Down Expand Up @@ -349,6 +362,7 @@
children = (
AF000001AAAA000100000001 /* SavedReply.swift */,
BF000001EEEE000100000001 /* FavoritesBackup.swift */,
CA000003CCCC000100000001 /* ChanPreferences.swift */,
EE000001EEEE000100000001 /* HiddenPostStore.swift */,
DD000002DDDD000100000001 /* GeneralSuggestion.swift */,
DD000001DDDD000100000001 /* ThreadReadingProgress.swift */,
Expand Down Expand Up @@ -452,6 +466,7 @@
958D2C98254D2DF200E54C41 /* ContentView.swift */,
95B7BE43256CBBCA00F75BC9 /* Boards */,
95C01FE827386D2B000D64B1 /* SettingsView.swift */,
CA000004CCCC000100000001 /* PostFiltersView.swift */,
950E3933255CD9BB00A00A74 /* Media */,
95D86F6F25EA301A005A7EBC /* Utility */,
2BA6BE71A1C0F2F67A42CF5E /* Favorites */,
Expand All @@ -468,6 +483,7 @@
9574CE06255E34E8000C99B6 /* CatalogViewModel.swift */,
9574CDFD255E30E8000C99B6 /* ThreadViewModel.swift */,
83D12254DE163971EE1E9188 /* RecurringFavoriteViewModel.swift */,
CA000005CCCC000100000001 /* ThreadWatcher.swift */,
);
path = ViewModels;
sourceTree = "<group>";
Expand Down Expand Up @@ -540,6 +556,8 @@
isa = PBXGroup;
children = (
9599CA95254DF8C0002EB026 /* ThreadView.swift */,
CA000002CCCC000100000001 /* ReplyDraftSheet.swift */,
CA000001CCCC000100000001 /* QuotePreviewSheet.swift */,
9599CA9F254DFDA1002EB026 /* PostView.swift */,
95D804CD25677FF4005A3128 /* RepliesView.swift */,
9559F79228443E7D00F5803F /* ThreadAutoRefresher.swift */,
Expand Down Expand Up @@ -810,6 +828,7 @@
AA000002AAAA000100000002 /* VideoPlayerControlsView.swift in Sources */,
9559F79328443E7D00F5803F /* ThreadAutoRefresher.swift in Sources */,
BF000001EEEE000100000002 /* FavoritesBackup.swift in Sources */,
CA000003CCCC000100000002 /* ChanPreferences.swift in Sources */,
EE000001EEEE000100000002 /* HiddenPostStore.swift in Sources */,
EE000002EEEE000100000002 /* HiddenPostsView.swift in Sources */,
DD000001DDDD000100000002 /* ThreadReadingProgress.swift in Sources */,
Expand All @@ -827,13 +846,16 @@
958D0998254D346A00AD4849 /* CatalogView.swift in Sources */,
95C73106256CA1F700F18257 /* UserSettings.swift in Sources */,
95C01FE927386D2B000D64B1 /* SettingsView.swift in Sources */,
CA000004CCCC000100000002 /* PostFiltersView.swift in Sources */,
951053712566018D002E4051 /* Board.swift in Sources */,
95105379256608E5002E4051 /* CommentParser.swift in Sources */,
95BF206B28C5A23D00DCB2D1 /* GIFView.swift in Sources */,
9524E1AA256F87EA00D08075 /* CoreExtensions.swift in Sources */,
9524E1AF256F8E4D00D08075 /* Blur.swift in Sources */,
9559F78F2843B07D00F5803F /* NSFWTagView.swift in Sources */,
9599CA96254DF8C0002EB026 /* ThreadView.swift in Sources */,
CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */,
CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */,
95181791254CFB600032616E /* swiftchanApp.swift in Sources */,
95978F162CA7981600C520EF /* UINavigationController.swift in Sources */,
9587A51C255726D80028A143 /* CacheService.swift in Sources */,
Expand Down Expand Up @@ -880,9 +902,11 @@
B3097C1AEE48110A2ECBCCA5 /* FavoriteThreadRow.swift in Sources */,
09B1DFCA9F650DAC27B6DB21 /* RecurringFavorite.swift in Sources */,
1A5F76AD0CB8E1E22917C335 /* RecurringFavoriteViewModel.swift in Sources */,
CA000005CCCC000100000002 /* ThreadWatcher.swift in Sources */,
6B83449B7D7B012156947080 /* RecurringFavoriteRow.swift in Sources */,
4B1F4B029F952735A2843BFF /* RecurringMatchSheet.swift in Sources */,
BF000003EEEE000100000002 /* FavoritesBackupView.swift in Sources */,
CA000006CCCC000100000002 /* ThreadWatcherView.swift in Sources */,
2C999DFDE990B515FFB0B779 /* AddRecurringFavoriteSheet.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
85 changes: 85 additions & 0 deletions swiftchan/Models/ChanPreferences.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import SwiftUI
import FourChan
import Observation

enum ChanTheme: String, CaseIterable, Identifiable {
case system = "System", yotsuba = "Yotsuba", yotsubaB = "Yotsuba B"
var id: String { rawValue }
var postBackground: Color {
switch self {
case .system: return Color(UIColor.systemBackground)
case .yotsuba: return Color(red: 0.94, green: 0.88, blue: 0.84)
case .yotsubaB: return Color(red: 0.84, green: 0.85, blue: 0.94)
}
}
var pageBackground: Color {
switch self {
case .system: return Color(UIColor.systemBackground)
case .yotsuba: return Color(red: 1, green: 1, blue: 0.93)
case .yotsubaB: return Color(red: 0.93, green: 0.95, blue: 1)
}
}
}

struct PostFilterRule: Codable, Identifiable, Equatable {
enum Field: String, Codable, CaseIterable { case keyword = "Keyword", posterID = "Poster ID", name = "Name", tripcode = "Tripcode" }
enum Action: String, Codable, CaseIterable { case hide = "Hide", highlight = "Highlight" }
var id = UUID()
var board: String?
var field: Field
var pattern: String
var action: Action

func matches(board: String, text: String, posterID: String?, name: String?, tripcode: String?) -> Bool {
guard self.board == nil || self.board == board.lowercased(), !pattern.isEmpty else { return false }
let value: String
switch field {
case .keyword: value = text
case .posterID: return posterID?.caseInsensitiveCompare(pattern) == .orderedSame
case .name: value = name ?? ""
case .tripcode: value = tripcode ?? ""
}
return value.localizedStandardContains(pattern)
}
}

@Observable @MainActor
final class PostFilterStore {
static let shared = PostFilterStore(defaults: ProcessInfo.processInfo.arguments.contains("--ui-testing") ? nil : .standard)
private(set) var rules: [PostFilterRule]
@ObservationIgnored private let defaults: UserDefaults?
private let key = "postFilters.v1"

init(defaults: UserDefaults? = .standard) {
self.defaults = defaults
rules = defaults?.data(forKey: key).flatMap { try? JSONDecoder().decode([PostFilterRule].self, from: $0) } ?? []
}

func add(_ rule: PostFilterRule) {
var rule = rule
rule.pattern = rule.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !rule.pattern.isEmpty else { return }
if let board = rule.board {
guard let normalized = Deeplinker.normalizedBoard(board) else { return }
rule.board = normalized
}
rules.append(rule)
persist()
}

func remove(_ rule: PostFilterRule) {
rules.removeAll { $0.id == rule.id }
persist()
}

func effect(board: String, post: Post, text: String) -> PostFilterRule.Action? {
let matches = rules.filter { $0.matches(board: board, text: "\(post.sub?.clean ?? "") \(text)",
posterID: post.pid, name: post.name, tripcode: post.trip) }
if matches.contains(where: { $0.action == .hide }) { return .hide }
return matches.isEmpty ? nil : .highlight
}

private func persist() {
if let data = try? JSONEncoder().encode(rules) { defaults?.set(data, forKey: key) }
}
}
19 changes: 16 additions & 3 deletions swiftchan/Services/ThreadUITestFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@ import FourChan
/// Deterministic thread content for UI tests; never used in release builds or normal launches.
enum ThreadUITestFixture {
static func load(board: String, id: Int) throws -> ChanThread? {
guard ProcessInfo.processInfo.arguments.contains("--ui-thread-fixture"), board == "biz", id == 100 else { return nil }
let posts: [[String: Any]] = (100...114).map { number in
let arguments = ProcessInfo.processInfo.arguments
guard arguments.contains("--ui-thread-fixture"), board == "biz",
id == 100 || (arguments.contains("--ui-general-rollover") && id == 200) else { return nil }
let posts: [[String: Any]] = (id...(id + 14)).map { number in
var post: [String: Any] = ["no": number, "time": 1_700_000_000, "name": "Anonymous",
"com": "Reply \(number). " + String(repeating: "A sample discussion about collecting coins and precious metals. ", count: 6)]
if number == 100 { post["sub"] = "/pmg/ - Precious Metals General" }
if number == id {
post["sub"] = "/pmg/ - Precious Metals General"
if arguments.contains("--ui-general-rollover"), id == 100 { post["archived"] = 1 }
}
if arguments.contains("--ui-quote-fixture"), number == 105 || number == 104 {
post["com"] = "<a class=\"quotelink\" href=\"#p\(number - 1)\">&gt;&gt;\(number - 1)</a><br>Quoted reply \(number)"
}
return post
}
return try JSONDecoder().decode(ChanThread.self, from: JSONSerialization.data(withJSONObject: ["posts": posts]))
}

static func catalog(board: String) throws -> Catalog? {
guard ProcessInfo.processInfo.arguments.contains("--ui-general-rollover"), board == "biz" else { return nil }
return try JSONDecoder().decode(Catalog.self, from: Data(#"[{"page":1,"threads":[{"no":100,"sub":"/pmg/ - Old"},{"no":200,"sub":"/pmg/ - New"}]}]"#.utf8))
}
}
#endif
11 changes: 8 additions & 3 deletions swiftchan/ViewModels/RecurringFavoriteViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ class RecurringFavoriteViewModel {
@ObservationIgnored private let parseComment: (String) -> AttributedString
@ObservationIgnored private var activeRequest: UUID?

init(fetchCatalog: @escaping CatalogLoader = { try await FourChanAsyncService.shared.getCatalog(boardName: $0) },
init(fetchCatalog: @escaping CatalogLoader = {
#if DEBUG
if let catalog = try ThreadUITestFixture.catalog(board: $0) { return catalog }
#endif
return try await FourChanAsyncService.shared.getCatalog(boardName: $0)
},
parseComment: @escaping (String) -> AttributedString = { CommentParser(comment: $0).getComment() }) {
self.fetchCatalog = fetchCatalog
self.parseComment = parseComment
Expand Down Expand Up @@ -47,7 +52,7 @@ class RecurringFavoriteViewModel {

var state: MatchState = .idle

func findMatches(for favorite: RecurringFavorite) async {
func findMatches(for favorite: RecurringFavorite, excludingThreadID: Int? = nil) async {
let request = UUID()
activeRequest = request
state = .loading
Expand All @@ -60,7 +65,7 @@ class RecurringFavoriteViewModel {
let pattern = favorite.searchPattern.lowercased()
// Filter cheap title strings before parsing rich comments for the matching threads.
let matchingThreads = catalog.flatMap(\.threads).enumerated().filter {
($0.element.sub?.clean.lowercased() ?? "").contains(pattern)
$0.element.no > (excludingThreadID ?? 0) && ($0.element.sub?.clean.lowercased() ?? "").contains(pattern)
}.sorted { $0.element.no > $1.element.no }
let matches = matchingThreads.map { index, thread in
SwiftchanPost(post: thread, boardName: favorite.boardName,
Expand Down
17 changes: 13 additions & 4 deletions swiftchan/ViewModels/ThreadViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ final class ThreadViewModel {
private(set) var errorType = ErrorType.generic
private(set) var refreshError: String?
private(set) var isArchived = false
private(set) var isFromArchive = false
private(set) var progressText = ""
private(set) var downloadProgress = Progress()
private var cancellables: Set<AnyCancellable> = []
Expand Down Expand Up @@ -80,7 +81,7 @@ final class ThreadViewModel {
}

var archiveUrl: URL? {
FourplebsService.archiveUrl(board: boardName, threadNum: id)
isFromArchive ? FourplebsService.archiveUrl(board: boardName, threadNum: id) : nil
}

/// Lazily parses and caches the AttributedString for a post's comment.
Expand Down Expand Up @@ -161,7 +162,7 @@ final class ThreadViewModel {
LoadedPost(post: post, mediaURL: post.getMediaUrl(boardId: boardName),
thumbnailURL: post.getMediaUrl(boardId: boardName, thumbnail: true))
}
apply(contents, archived: false)
apply(contents, archived: thread.posts.first?.archived == 1)
return true
} catch {
handleFailure(error)
Expand All @@ -188,7 +189,7 @@ final class ThreadViewModel {
reportFailure(.notFound)
return false
}
apply(contents, archived: true)
apply(contents, archived: true, fromArchive: true)
return true
} catch {
handleFailure(error)
Expand All @@ -213,7 +214,7 @@ final class ThreadViewModel {
}

/// Install live and archived responses together so every index refers to the same snapshot.
private func apply(_ contents: [LoadedPost], archived: Bool) {
private func apply(_ contents: [LoadedPost], archived: Bool, fromArchive: Bool = false) {
updateProgress(50, message: "Processing posts...")
var mediaURLs: [URL] = []
var thumbnailURLs: [URL] = []
Expand All @@ -238,6 +239,7 @@ final class ThreadViewModel {
buildPostIdIndex()
setMedia(mediaUrls: mediaURLs, thumbnailMediaUrls: thumbnailURLs)
isArchived = archived
isFromArchive = fromArchive
errorType = .generic
updateSearchResults()
updateProgress(100, message: "Complete!")
Expand Down Expand Up @@ -341,6 +343,7 @@ final class ThreadViewModel {
let searchTextLowercased = searchText.lowercased()

for (index, post) in posts.enumerated() {
if filterEffect(at: index) == .hide { continue }
var matchesSearch = true

if !searchText.isEmpty {
Expand Down Expand Up @@ -380,6 +383,12 @@ final class ThreadViewModel {
return filteredIndices
}

func filterEffect(at index: Int) -> PostFilterRule.Action? {
guard posts.indices.contains(index) else { return nil }
return PostFilterStore.shared.effect(board: boardName, post: posts[index],
text: index < searchableComments.count ? searchableComments[index] : "")
}

func updateSearchResults() {
searchResultIndices = getFilteredPostIndices()
searchResultIndexSet = Set(searchResultIndices)
Expand Down
Loading
Loading