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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,11 @@ If you want to test it with me on testflight or think we can get it on the app s
<!-- USAGE EXAMPLES -->
## Usage

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.

In a thread, use **Jump → First Post / Latest Reply** to move through long discussions without repeated scrolling. Hidden posts are skipped.

Use **Settings → Favorites Backup** to export saved thread links and named generals as JSON. Importing a backup adds missing entries while keeping existing names. Backups do not include media files, settings, or reading progress.
Use **Settings → Favorites Backup** to export saved thread links, named generals, and saved replies as JSON. Importing a backup adds missing entries while keeping existing names and reply snapshots. Older backups still import; backups containing saved replies require a version of Swiftchan with reply bookmarks. Backups do not include media files, settings, or reading progress.

Use **Open Link** on the Boards tab to jump to a board by entering its shortcut, such as `/biz/`, or paste a full board or thread URL.

Expand Down
12 changes: 12 additions & 0 deletions swiftchan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
objects = {

/* Begin PBXBuildFile section */
AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000003AAAA000100000001 /* SavedReplyTests.swift */; };
AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000002AAAA000100000001 /* SavedRepliesView.swift */; };
AF000001AAAA000100000002 /* SavedReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000001AAAA000100000001 /* SavedReply.swift */; };
BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000004EEEE000100000001 /* FavoritesBackupTests.swift */; };
BF000003EEEE000100000002 /* FavoritesBackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000003EEEE000100000001 /* FavoritesBackupView.swift */; };
BF000002EEEE000100000002 /* FavoritesBackupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000002EEEE000100000001 /* FavoritesBackupStore.swift */; };
Expand Down Expand Up @@ -151,6 +154,9 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
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>"; };
AF000001AAAA000100000001 /* SavedReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReply.swift; sourceTree = "<group>"; };
BF000004EEEE000100000001 /* FavoritesBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupTests.swift; sourceTree = "<group>"; };
BF000003EEEE000100000001 /* FavoritesBackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupView.swift; sourceTree = "<group>"; };
BF000002EEEE000100000001 /* FavoritesBackupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupStore.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -297,6 +303,7 @@
1F62ABB509907B2E2D948712 /* Favorites */ = {
isa = PBXGroup;
children = (
AF000002AAAA000100000001 /* SavedRepliesView.swift */,
5DAF769CBA7D852E789BB9FE /* FavoritesView.swift */,
D01E215C9A3306E1A631FA2C /* FavoriteThreadRow.swift */,
E087391199AA472B45F68D1C /* RecurringFavoriteRow.swift */,
Expand Down Expand Up @@ -338,6 +345,7 @@
9510536725660071002E4051 /* Models */ = {
isa = PBXGroup;
children = (
AF000001AAAA000100000001 /* SavedReply.swift */,
BF000001EEEE000100000001 /* FavoritesBackup.swift */,
EE000001EEEE000100000001 /* HiddenPostStore.swift */,
DD000002DDDD000100000001 /* GeneralSuggestion.swift */,
Expand Down Expand Up @@ -406,6 +414,7 @@
951817A1254CFB620032616E /* swiftchanTests */ = {
isa = PBXGroup;
children = (
AF000003AAAA000100000001 /* SavedReplyTests.swift */,
BF000004EEEE000100000001 /* FavoritesBackupTests.swift */,
EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */,
DD000004DDDD000100000001 /* ReadingProgressTests.swift */,
Expand Down Expand Up @@ -784,6 +793,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */,
AF000001AAAA000100000002 /* SavedReply.swift in Sources */,
954FB5832730BDF0009CC52F /* RoundedCorners.swift in Sources */,
95CEE159271A67590016EAC1 /* SortRow.swift in Sources */,
9524F4B62580545C0006395F /* FileExport.swift in Sources */,
Expand Down Expand Up @@ -876,6 +887,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */,
BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */,
EE000003EEEE000100000002 /* HiddenPostStoreTests.swift in Sources */,
DD000004DDDD000100000002 /* ReadingProgressTests.swift in Sources */,
Expand Down
36 changes: 30 additions & 6 deletions swiftchan/Models/FavoritesBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,34 @@ struct FavoritesBackup: Codable, Equatable {
struct ImportPlan {
let threads: [Thread]
let generals: [General]
let replies: [Reply]
let skipped: Int
}

struct Reply: Codable, Equatable {
var board: String
let threadID: Int
let postID: Int
let title: String
let text: String
let savedAt: Date
var key: String { "\(board)/\(postID)" }
}

let format: String
let version: Int
let exportedAt: Date
var threads: [Thread]
var generals: [General]
var replies: [Reply]?

init(threads: [Thread], generals: [General], exportedAt: Date = Date()) {
init(threads: [Thread], generals: [General], replies: [Reply]? = nil, exportedAt: Date = Date()) {
format = "swiftchan.favorites"
version = 1
version = replies?.isEmpty == false ? 2 : 1
self.exportedAt = exportedAt
self.threads = threads
self.generals = generals
self.replies = replies
}

func encoded() throws -> Data {
Expand All @@ -58,7 +71,7 @@ struct FavoritesBackup: Codable, Equatable {

func validated() throws -> Self {
guard format == "swiftchan.favorites" else { throw BackupError.invalidFile }
guard version == 1 else { throw BackupError.unsupportedVersion }
guard version == 1 || version == 2 else { throw BackupError.unsupportedVersion }
var result = self
for index in result.threads.indices {
guard let board = Deeplinker.normalizedBoard(result.threads[index].board), result.threads[index].number > 0 else {
Expand All @@ -76,18 +89,29 @@ struct FavoritesBackup: Codable, Equatable {
let name = result.generals[index].name?.trimmingCharacters(in: .whitespacesAndNewlines)
result.generals[index].name = name?.isEmpty == false ? name : nil
}
if var replies = result.replies {
for index in replies.indices {
guard let board = Deeplinker.normalizedBoard(replies[index].board),
replies[index].threadID > 0, replies[index].postID >= replies[index].threadID else { throw BackupError.invalidFile }
replies[index].board = board
}
result.replies = replies
}
return result
}

/// Existing favorites win; repeated records in a file are imported only once.
func importPlan(threadKeys: Set<String>, generalKeys: Set<String>) throws -> ImportPlan {
func importPlan(threadKeys: Set<String>, generalKeys: Set<String>, replyKeys: Set<String> = []) throws -> ImportPlan {
let backup = try validated()
var threadsSeen = threadKeys
var generalsSeen = generalKeys
var repliesSeen = replyKeys
let threads = backup.threads.filter { threadsSeen.insert($0.key).inserted }
let generals = backup.generals.filter { generalsSeen.insert($0.key).inserted }
return ImportPlan(threads: threads, generals: generals,
skipped: backup.threads.count + backup.generals.count - threads.count - generals.count)
let replies = (backup.replies ?? []).filter { repliesSeen.insert($0.key).inserted }
return ImportPlan(threads: threads, generals: generals, replies: replies,
skipped: backup.threads.count + backup.generals.count + (backup.replies?.count ?? 0)
- threads.count - generals.count - replies.count)
}

enum BackupError: LocalizedError {
Expand Down
12 changes: 9 additions & 3 deletions swiftchan/Models/FavoritesSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,26 @@ enum FavoritesSchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] { [FavoriteThread.self, RecurringFavorite.self] }
}

enum FavoritesSchemaV3: VersionedSchema {
static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) }
static var models: [any PersistentModel.Type] { [FavoriteThread.self, RecurringFavorite.self, SavedReply.self] }
}

enum FavoritesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [FavoritesSchemaV1.self, FavoritesSchemaV2.self] }
static var schemas: [any VersionedSchema.Type] { [FavoritesSchemaV1.self, FavoritesSchemaV2.self, FavoritesSchemaV3.self] }
static var stages: [MigrationStage] {
// V1's globally unique thread IDs already guarantee unique (board, thread) pairs.
// No rows need to be discarded when replacing that constraint.
[.custom(fromVersion: FavoritesSchemaV1.self, toVersion: FavoritesSchemaV2.self,
willMigrate: nil, didMigrate: nil)]
willMigrate: nil, didMigrate: nil),
.lightweight(fromVersion: FavoritesSchemaV2.self, toVersion: FavoritesSchemaV3.self)]
}
}

@MainActor
enum FavoritesStore {
static func makeContainer(configuration: ModelConfiguration = ModelConfiguration()) throws -> ModelContainer {
try ModelContainer(for: Schema(versionedSchema: FavoritesSchemaV2.self),
try ModelContainer(for: Schema(versionedSchema: FavoritesSchemaV3.self),
migrationPlan: FavoritesMigrationPlan.self, configurations: configuration)
}
}
27 changes: 27 additions & 0 deletions swiftchan/Models/SavedReply.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import SwiftData

@Model
final class SavedReply {
#Unique<SavedReply>([\.boardName, \.postID])

var boardName: String
var threadID: Int
var postID: Int
var threadTitle: String
var text: String
var savedAt: Date

init(boardName: String, threadID: Int, postID: Int, threadTitle: String, text: String, savedAt: Date = Date()) {
self.boardName = boardName
self.threadID = threadID
self.postID = postID
self.threadTitle = threadTitle
self.text = text
self.savedAt = savedAt
}

var url: URL {
URL(string: "https://boards.4chan.org/\(boardName)/thread/\(threadID)#p\(postID)")!
}
}
16 changes: 14 additions & 2 deletions swiftchan/Services/FavoritesBackupStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@ enum FavoritesBackupStore {
static func export(from context: ModelContext) throws -> FavoritesBackup {
let threads = try context.fetch(FetchDescriptor<FavoriteThread>(sortBy: [SortDescriptor(\.savedAt)]))
let generals = try context.fetch(FetchDescriptor<RecurringFavorite>(sortBy: [SortDescriptor(\.createdAt)]))
let replies = try context.fetch(FetchDescriptor<SavedReply>(sortBy: [SortDescriptor(\.savedAt)]))
return FavoritesBackup(threads: threads.map {
.init(board: $0.boardName, number: $0.threadId, title: $0.title, createdAt: $0.createdTime, savedAt: $0.savedAt)
}, generals: generals.map {
.init(board: $0.boardName, tag: $0.searchPattern, name: $0.displayName, createdAt: $0.createdAt)
}, replies: replies.map {
.init(board: $0.boardName, threadID: $0.threadID, postID: $0.postID, title: $0.threadTitle, text: $0.text, savedAt: $0.savedAt)
})
}

static func restore(_ backup: FavoritesBackup, in context: ModelContext) throws -> FavoritesBackup.ImportPlan {
let existing = try export(from: context).validated()
let plan = try backup.importPlan(threadKeys: Set(existing.threads.map(\.key)), generalKeys: Set(existing.generals.map(\.key)))
let plan = try backup.importPlan(threadKeys: Set(existing.threads.map(\.key)), generalKeys: Set(existing.generals.map(\.key)),
replyKeys: Set((existing.replies ?? []).map(\.key)))
var insertedThreads: [FavoriteThread] = []
var insertedGenerals: [RecurringFavorite] = []
var insertedReplies: [SavedReply] = []
for record in plan.threads {
let thread = FavoriteThread(threadId: record.number, boardName: record.board, title: record.title,
createdTime: record.createdAt, savedAt: record.savedAt)
Expand All @@ -30,13 +35,20 @@ enum FavoritesBackupStore {
context.insert(general)
insertedGenerals.append(general)
}
for record in plan.replies {
let reply = SavedReply(boardName: record.board, threadID: record.threadID, postID: record.postID,
threadTitle: record.title, text: record.text, savedAt: record.savedAt)
context.insert(reply)
insertedReplies.append(reply)
}
do {
if !insertedThreads.isEmpty || !insertedGenerals.isEmpty { try context.save() }
if !insertedThreads.isEmpty || !insertedGenerals.isEmpty || !insertedReplies.isEmpty { try context.save() }
return plan
} catch {
// Remove only this import's inserts; preserve unrelated edits in the shared context.
insertedThreads.forEach { context.delete($0) }
insertedGenerals.forEach { context.delete($0) }
insertedReplies.forEach { context.delete($0) }
throw error
}
}
Expand Down
26 changes: 25 additions & 1 deletion swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ struct ThreadView: View {
@Environment(AppState.self) private var appState
@Environment(\.modelContext) private var modelContext
@Query private var followedGenerals: [RecurringFavorite]
@Query private var savedReplies: [SavedReply]
@State private var bookmarkError: String?

@State private var presentationState = PresentationState()
@State private var threadAutorefresher = ThreadAutoRefresher()
Expand Down Expand Up @@ -349,6 +351,13 @@ struct ThreadView: View {
if let post = appState.selectedBottomSheetPost,
let index = viewModel.posts.firstIndex(of: post) {
VStack(spacing: 24) {
let isSaved = savedReplies.contains { $0.boardName == viewModel.boardName && $0.postID == post.no }
Button(isSaved ? "Remove Saved Reply" : "Save Reply", systemImage: isSaved ? "bookmark.slash" : "bookmark") {
saveReply(index: index)
appState.showingBottomSheet = false
appState.selectedBottomSheetPost = nil
}
.accessibilityIdentifier("Save Selected Reply")
ShareLink(item: viewModel.postURL(post.no)) {
Label("Share Post", systemImage: "square.and.arrow.up")
}
Expand All @@ -362,7 +371,7 @@ struct ThreadView: View {
.accessibilityIdentifier("Hide Selected Post")
}
.padding()
.presentationDetents([.height(180), .medium])
.presentationDetents([.height(230), .medium])
.presentationDragIndicator(.visible)
}
}
Expand All @@ -385,6 +394,9 @@ struct ThreadView: View {
.background(.regularMaterial)
}
}
.alert("Couldn’t Save Reply", isPresented: Binding(get: { bookmarkError != nil }, set: { if !$0 { bookmarkError = nil } })) {
Button("OK", role: .cancel) { bookmarkError = nil }
} message: { Text(bookmarkError ?? "") }
.toolbar(hideTabOnBoards ? .hidden : .automatic, for: .tabBar)
case .error:
let _ = print("DEBUG View: errorType=\(viewModel.errorType), canLoadFromArchive=\(viewModel.canLoadFromArchive), board=\(viewModel.boardName)")
Expand Down Expand Up @@ -596,6 +608,18 @@ struct ThreadView: View {
isFavorited = ((try? modelContext.fetchCount(descriptor)) ?? 0) > 0
}

private func saveReply(index: Int) {
let post = viewModel.posts[index]
if let existing = savedReplies.first(where: { $0.boardName == viewModel.boardName && $0.postID == post.no }) {
modelContext.delete(existing)
} else {
let text = post.com.map { String(CommentParser(comment: $0).getComment().characters) } ?? ""
modelContext.insert(SavedReply(boardName: viewModel.boardName, threadID: viewModel.id,
postID: post.no, threadTitle: viewModel.title, text: text))
}
do { try modelContext.save() } catch { bookmarkError = error.localizedDescription }
}

private func toggleFavorite() {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()

Expand Down
Loading
Loading