From 1d24bb2ccd06f0727868b2cb91867b642b52ce2c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 16:07:07 -0700 Subject: [PATCH 01/12] Added messages for build status, etc. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 7458ca3..eee4069 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # InterlinedList iOS +[![iOS Build](https://github.com/CompositeCode/interlinedlist-ios/actions/workflows/ios.yml/badge.svg)](https://github.com/CompositeCode/interlinedlist-ios/actions/workflows/ios.yml) +[![iOS 17+](https://img.shields.io/badge/iOS-17%2B-blue?logo=apple)](https://developer.apple.com/ios/) +[![Swift 5.9+](https://img.shields.io/badge/Swift-5.9%2B-orange?logo=swift)](https://swift.org) +[![Xcode 15+](https://img.shields.io/badge/Xcode-15%2B-147EFB?logo=xcode&logoColor=white)](https://developer.apple.com/xcode/) +[![No dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)](https://github.com/CompositeCode/interlinedlist-ios) + Native iOS app for [InterlinedList](https://interlinedlist.com) — a social list-sharing service. Sign in, browse a message feed, post and reply, and manage nested lists, documents, organizations, and your social graph. Built in SwiftUI with no third-party dependencies. ## Requirements From e838d06ca0e5359135597e2daf1e361b59464846 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 16:09:24 -0700 Subject: [PATCH 02/12] Changed wording. --- .claude/settings.local.json | 4 +++- InterlinedList/Views/ComposeView.swift | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9d7686e..b10d2cf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -75,7 +75,9 @@ "Bash(git ls-remote *)", "Bash(gh pr *)", "Bash(git push *)", - "Bash(echo \"---exit $?\")" + "Bash(echo \"---exit $?\")", + "Bash(git remote *)", + "Bash(sort -t: -k1,1 -k2,2n)" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index d35d33f..87825be 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -106,7 +106,7 @@ struct ComposeView: View { } if !isReply && !isRepost && !postableOrgs.isEmpty { - Section("Post as") { + Section("Post Message As") { Picker("Author", selection: $selectedOrgId) { Text("Yourself").tag(String?.none) ForEach(postableOrgs) { org in @@ -200,13 +200,13 @@ struct ComposeView: View { private var navTitle: String { if isReply { return "Reply" } if isRepost { return "Repost" } - return "New post" + return "New Message" } private var postButtonLabel: String { if isRepost { return "Repost" } if scheduledDate != nil { return "Schedule" } - return isReply ? "Reply" : "Post" + return isReply ? "Reply" : "Post Message" } /// Reposts may have empty commentary; everything else requires content. @@ -217,7 +217,7 @@ struct ComposeView: View { private var successTitle: String { if isReply { return "Replied" } if isRepost { return "Reposted" } - return scheduledDate != nil ? "Scheduled" : "Posted" + return scheduledDate != nil ? "Scheduled" : "Message Posted" } private var successMessage: String { @@ -304,7 +304,7 @@ struct ComposeView: View { .foregroundStyle(scheduledDate != nil ? ILColor.primary : Color.secondary) } .buttonStyle(.borderless) - .accessibilityLabel(scheduledDate != nil ? "Clear schedule" : "Schedule post") + .accessibilityLabel(scheduledDate != nil ? "Clear schedule" : "Schedule message") } } Spacer(minLength: 0) From ab344db6cbf4ba963ca456917177b91f0476b9f6 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 16:09:38 -0700 Subject: [PATCH 03/12] Fixed the feed view (hopefully). --- InterlinedList/Views/FeedView.swift | 41 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index dd56ca3..2837a28 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -227,12 +227,21 @@ struct FeedView: View { } return nav .onChange(of: store.feedMessages.count) { _, _ in - guard !syncedFromStore && !showOnlyMine && tagFilter == nil else { return } - let msgs = store.feedMessages - messages = msgs - initDigStates(from: msgs) - isLoading = false - syncedFromStore = !msgs.isEmpty + guard !showOnlyMine && tagFilter == nil else { return } + if !syncedFromStore { + let msgs = store.feedMessages + messages = msgs + initDigStates(from: msgs) + isLoading = false + syncedFromStore = !msgs.isEmpty + } else { + let existingIds = Set(messages.map { $0.id }) + let newMessages = store.feedMessages.filter { !existingIds.contains($0.id) } + if !newMessages.isEmpty { + messages.insert(contentsOf: newMessages, at: 0) + initDigStates(from: newMessages) + } + } } .onChange(of: store.feedLoading) { _, loading in guard !showOnlyMine && tagFilter == nil else { return } @@ -255,18 +264,18 @@ struct FeedView: View { Text("InterlinedList").font(.ilTitle()).foregroundStyle(.white) } } - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 4) { - // Scheduled posts are a subscriber-only feature; entry point hidden - // entirely for free users per the iOS-free-app direction. - if authState.user?.isSubscriber == true { - Button { showScheduled = true } label: { Image(systemName: "calendar") } - .accessibilityLabel("Scheduled posts") - } - Button { showCompose = true } label: { Image(systemName: "square.and.pencil") } - .accessibilityLabel("Compose") + // Scheduled posts are a subscriber-only feature; entry point hidden + // entirely for free users per the iOS-free-app direction. + if authState.user?.isSubscriber == true { + ToolbarItem(placement: .topBarTrailing) { + Button { showScheduled = true } label: { Image(systemName: "calendar") } + .accessibilityLabel("Scheduled posts") } } + ToolbarItem(placement: .topBarTrailing) { + Button { showCompose = true } label: { Image(systemName: "square.and.pencil") } + .accessibilityLabel("Compose") + } } private func applyInitialState() async { From 3e087346badc2e357f93ec982ecc0faa8a8888fa Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 17:02:46 -0700 Subject: [PATCH 04/12] App data store tweak. --- .claude/settings.local.json | 3 +- .../ServiceTests/AppDataStoreTests.swift | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b10d2cf..f886d6e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -77,7 +77,8 @@ "Bash(git push *)", "Bash(echo \"---exit $?\")", "Bash(git remote *)", - "Bash(sort -t: -k1,1 -k2,2n)" + "Bash(sort -t: -k1,1 -k2,2n)", + "Bash(gh run *)" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" diff --git a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift index 731ea35..63c41dc 100644 --- a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift +++ b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift @@ -1,3 +1,4 @@ +import Combine import XCTest @testable import InterlinedList @@ -37,6 +38,50 @@ final class AppDataStoreTests: XCTestCase { XCTAssertTrue(sut.feedMessages.contains { $0.id == "new" }) } + func test_insertFeedMessage_isVisibleAfterInitialMessages() { + sut.insertFeedMessage(makeMessage(id: "existing1")) + sut.insertFeedMessage(makeMessage(id: "existing2")) + sut.insertFeedMessage(makeMessage(id: "existing3")) + sut.insertFeedMessage(makeMessage(id: "newPost")) + XCTAssertEqual(sut.feedMessages.first?.id, "newPost") + XCTAssertTrue(sut.feedMessages.contains { $0.id == "existing1" }) + XCTAssertTrue(sut.feedMessages.contains { $0.id == "existing2" }) + XCTAssertTrue(sut.feedMessages.contains { $0.id == "existing3" }) + XCTAssertEqual(sut.feedMessages.count, 4) + } + + func test_insertFeedMessage_newMessageAppearsBeforeExisting() { + sut.insertFeedMessage(makeMessage(id: "first")) + sut.insertFeedMessage(makeMessage(id: "second")) + sut.insertFeedMessage(makeMessage(id: "third")) + XCTAssertEqual(sut.feedMessages[0].id, "third") + XCTAssertEqual(sut.feedMessages[1].id, "second") + XCTAssertEqual(sut.feedMessages[2].id, "first") + } + + func test_insertFeedMessage_doesNotDuplicateExistingMessage() { + sut.insertFeedMessage(makeMessage(id: "dup")) + sut.insertFeedMessage(makeMessage(id: "dup")) + // insertFeedMessage does not deduplicate at the store layer. + // FeedView's onChange filters by !existingIds.contains($0.id) before prepending. + XCTAssertEqual(sut.feedMessages.count, 2) + } + + func test_insertFeedMessage_publishesCountChange() { + let countIncreased = expectation(description: "feedMessages count increases") + var cancellables = Set() + sut.$feedMessages + .dropFirst() + .sink { messages in + if messages.count == 1 { + countIncreased.fulfill() + } + } + .store(in: &cancellables) + sut.insertFeedMessage(makeMessage(id: "z")) + wait(for: [countIncreased], timeout: 1.0) + } + // MARK: - reset func test_reset_clearsFeedMessages() { From 9a1951343853f996cebde48ca1b88120af1f1eed Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 18:57:48 -0700 Subject: [PATCH 05/12] Numerous fixes, that is what I have got. --- InterlinedList/Models/List.swift | 2 +- InterlinedList/Services/AppDataStore.swift | 58 ++++++---- InterlinedList/Services/DataCache.swift | 6 +- InterlinedList/Views/FeedView.swift | 24 ++++- InterlinedList/Views/ListsView.swift | 118 +++++++++++---------- InterlinedList/Views/UserProfileView.swift | 11 +- PROMPT.md | 0 7 files changed, 125 insertions(+), 94 deletions(-) create mode 100644 PROMPT.md diff --git a/InterlinedList/Models/List.swift b/InterlinedList/Models/List.swift index 4771547..def61ff 100644 --- a/InterlinedList/Models/List.swift +++ b/InterlinedList/Models/List.swift @@ -118,7 +118,7 @@ struct UserList: Identifiable, Codable, Hashable { } } -struct ListFolder: Identifiable, Codable { +struct ListFolder: Identifiable, Codable, Equatable { let id: String let name: String let parentId: String? diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index 4592d64..b90685a 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -24,13 +24,13 @@ final class AppDataStore: ObservableObject { @Published private(set) var unreadCount = 0 @Published private(set) var pendingRequestCount = 0 - private let cache = DataCache.shared + private let cache = DataCache() private var userId: String? func prefetchAll(userId: String?) async { if let uid = userId, self.userId != uid { self.userId = uid - loadFromCache(userId: uid) + await loadFromCache(userId: uid) } await withTaskGroup(of: Void.self) { group in group.addTask { await self.refreshFeed() } @@ -43,8 +43,9 @@ final class AppDataStore: ObservableObject { func onUserIdAvailable(_ id: String) { guard userId != id else { return } userId = id - if feedMessages.isEmpty { loadFromCache(userId: id) } - saveToCache() + if feedMessages.isEmpty { + Task { await loadFromCache(userId: id) } + } } func refreshFeed() async { @@ -54,7 +55,7 @@ final class AppDataStore: ObservableObject { do { let (list, _) = try await APIClient.shared.messages(limit: 50, offset: 0, onlyMine: false, tag: nil) feedMessages = list - saveToCache() + saveFeedCache() } catch APIError.status(401) { } catch APIError.server(let msg) { if feedMessages.isEmpty { feedError = msg } @@ -71,7 +72,7 @@ final class AppDataStore: ObservableObject { let result = try await APIClient.shared.listsAndFolders() listFolders = result.folders userLists = result.lists - saveToCache() + saveListsCache() } catch APIError.status(401) { } catch APIError.server(let msg) { if userLists.isEmpty { listsError = msg } @@ -90,7 +91,7 @@ final class AppDataStore: ObservableObject { let (f, d) = try await (fTask, dTask) documentFolders = f documents = d - saveToCache() + saveDocsCache() } catch APIError.status(401) { } catch { // GET /api/documents is not documented as subscriber-only, so a @@ -119,20 +120,20 @@ final class AppDataStore: ObservableObject { func insertFeedMessage(_ message: Message) { feedMessages.insert(message, at: 0) - saveToCache() + saveFeedCache() } - func removeList(id: String) { userLists.removeAll { $0.id == id }; saveToCache() } - func removeListFolder(id: String) { listFolders.removeAll { $0.id == id }; saveToCache() } + func removeList(id: String) { userLists.removeAll { $0.id == id }; saveListsCache() } + func removeListFolder(id: String) { listFolders.removeAll { $0.id == id }; saveListsCache() } - func insertDocument(_ doc: Document) { documents.insert(doc, at: 0); saveToCache() } + func insertDocument(_ doc: Document) { documents.insert(doc, at: 0); saveDocsCache() } func updateDocument(_ doc: Document) { if let idx = documents.firstIndex(where: { $0.id == doc.id }) { documents[idx] = doc } - saveToCache() + saveDocsCache() } - func removeDocument(id: String) { documents.removeAll { $0.id == id }; saveToCache() } - func insertDocumentFolder(_ folder: DocumentFolder) { documentFolders.append(folder); saveToCache() } - func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; saveToCache() } + func removeDocument(id: String) { documents.removeAll { $0.id == id }; saveDocsCache() } + func insertDocumentFolder(_ folder: DocumentFolder) { documentFolders.append(folder); saveDocsCache() } + func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; saveDocsCache() } func reset() { feedMessages = [] @@ -153,23 +154,34 @@ final class AppDataStore: ObservableObject { // MARK: - Cache - private func loadFromCache(userId: String) { - if let msgs: [Message] = cache.load(key: "\(userId)_feed") { feedMessages = msgs } - if let cached: ListsCache = cache.load(key: "\(userId)_lists") { + private func loadFromCache(userId: String) async { + if let msgs: [Message] = await cache.load(key: "\(userId)_feed") { feedMessages = msgs } + if let cached: ListsCache = await cache.load(key: "\(userId)_lists") { listFolders = cached.folders userLists = cached.lists } - if let cached: DocsCache = cache.load(key: "\(userId)_docs") { + if let cached: DocsCache = await cache.load(key: "\(userId)_docs") { documentFolders = cached.folders documents = cached.documents } } - private func saveToCache() { + private func saveFeedCache() { + guard let uid = userId else { return } + let snapshot = feedMessages + Task { await cache.save(snapshot, key: "\(uid)_feed") } + } + + private func saveListsCache() { + guard let uid = userId else { return } + let snapshot = ListsCache(folders: listFolders, lists: userLists) + Task { await cache.save(snapshot, key: "\(uid)_lists") } + } + + private func saveDocsCache() { guard let uid = userId else { return } - cache.save(feedMessages, key: "\(uid)_feed") - cache.save(ListsCache(folders: listFolders, lists: userLists), key: "\(uid)_lists") - cache.save(DocsCache(folders: documentFolders, documents: documents), key: "\(uid)_docs") + let snapshot = DocsCache(folders: documentFolders, documents: documents) + Task { await cache.save(snapshot, key: "\(uid)_docs") } } } diff --git a/InterlinedList/Services/DataCache.swift b/InterlinedList/Services/DataCache.swift index 8724bbb..5d198b6 100644 --- a/InterlinedList/Services/DataCache.swift +++ b/InterlinedList/Services/DataCache.swift @@ -5,14 +5,12 @@ import Foundation -final class DataCache { - static let shared = DataCache() - +actor DataCache { private let encoder = JSONEncoder() private let decoder = JSONDecoder() private let cacheDir: URL - private init() { + init() { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] cacheDir = base.appendingPathComponent("ILDataCache", isDirectory: true) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index 2837a28..3443dc7 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -268,13 +268,29 @@ struct FeedView: View { // entirely for free users per the iOS-free-app direction. if authState.user?.isSubscriber == true { ToolbarItem(placement: .topBarTrailing) { - Button { showScheduled = true } label: { Image(systemName: "calendar") } - .accessibilityLabel("Scheduled posts") + Button { showScheduled = true } label: { + Image(systemName: "calendar") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(.white.opacity(0.15)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Scheduled posts") } } ToolbarItem(placement: .topBarTrailing) { - Button { showCompose = true } label: { Image(systemName: "square.and.pencil") } - .accessibilityLabel("Compose") + Button { showCompose = true } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(.white.opacity(0.15)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Compose") } } diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 3607f12..151da47 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -14,12 +14,13 @@ struct ListsView: View { @State private var searchText = "" @State private var searchResults: [UserList] = [] @State private var isSearching = false + @State private var treeNodes: [ListTreeNode] = [] private var canCreateFolders: Bool { authState.user?.isSubscriber == true } - private var treeNodes: [ListTreeNode] { + private func rebuildTree() -> [ListTreeNode] { // Folders are a subscriber-only feature. For free users we pass an empty // folder array so any lists that were nested under folders (e.g. from when // the user was a subscriber) surface at root via buildTree's orphan rule. @@ -29,43 +30,7 @@ struct ListsView: View { var body: some View { NavigationStack { - Group { - if !searchText.isEmpty { - searchResultsList - } else if store.listsLoading && treeNodes.isEmpty { - ListSkeletonView() - } else if let error = store.listsError, treeNodes.isEmpty { - ContentUnavailableView { - Label("Unable to load", systemImage: "exclamationmark.triangle") - } description: { - Text(error) - } actions: { - Button("Retry") { - Task { await store.refreshLists() } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if treeNodes.isEmpty { - ContentUnavailableView { - Label("No Lists", systemImage: "list.bullet.rectangle") - } description: { - Text("No lists found.") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - ForEach(treeNodes) { node in - ListTreeNodeRow( - node: node, - onDeleteList: { list in Task { await deleteList(list) } }, - onDeleteFolder: { folder in Task { await deleteFolder(folder) } }, - onUpdateList: { list in Task { await store.refreshLists() } } - ) - } - } - .listStyle(.sidebar) - } - } + listContent .navigationTitle("Lists") .navigationBarTitleDisplayMode(.large) .navigationDestination(for: UserList.self) { list in @@ -82,25 +47,7 @@ struct ListsView: View { } } .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - showCreateList = true - } label: { - Label("New List", systemImage: "plus.rectangle") - } - if canCreateFolders { - Button { - showCreateFolder = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - } - } label: { - Image(systemName: "plus") - } - .accessibilityLabel("New item") - } + ToolbarItem(placement: .topBarTrailing) { addMenu } } .sheet(isPresented: $showCreateList) { CreateListView { _ in @@ -115,7 +62,64 @@ struct ListsView: View { .refreshable { await store.refreshLists() } + .onAppear { treeNodes = rebuildTree() } + .onChange(of: store.userLists) { _, _ in treeNodes = rebuildTree() } + .onChange(of: store.listFolders) { _, _ in treeNodes = rebuildTree() } + } + } + + @ViewBuilder + private var listContent: some View { + if !searchText.isEmpty { + searchResultsList + } else if store.listsLoading && treeNodes.isEmpty { + ListSkeletonView() + } else if let error = store.listsError, treeNodes.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await store.refreshLists() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if treeNodes.isEmpty { + ContentUnavailableView { + Label("No Lists", systemImage: "list.bullet.rectangle") + } description: { + Text("No lists found.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(treeNodes) { node in + ListTreeNodeRow( + node: node, + onDeleteList: { list in Task { await deleteList(list) } }, + onDeleteFolder: { folder in Task { await deleteFolder(folder) } }, + onUpdateList: { _ in Task { await store.refreshLists() } } + ) + } + } + .listStyle(.sidebar) + } + } + + @ViewBuilder + private var addMenu: some View { + Menu { + Button { showCreateList = true } label: { + Label("New List", systemImage: "plus.rectangle") + } + if canCreateFolders { + Button { showCreateFolder = true } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + } + } label: { + Image(systemName: "plus") } + .accessibilityLabel("New item") } @ViewBuilder diff --git a/InterlinedList/Views/UserProfileView.swift b/InterlinedList/Views/UserProfileView.swift index 685ea33..299e0e8 100644 --- a/InterlinedList/Views/UserProfileView.swift +++ b/InterlinedList/Views/UserProfileView.swift @@ -45,6 +45,8 @@ struct UserProfileView: View { VStack(spacing: 0) { if authState.user?.username != username { followHeader + } else { + organizationsSection } Picker("Content", selection: $selectedTab) { @@ -62,7 +64,6 @@ struct UserProfileView: View { } if authState.user?.username == username { - organizationsSection exportSection } } @@ -99,10 +100,10 @@ struct UserProfileView: View { } } .task { - await loadMessages() - if authState.user?.username == username && !organizationsLoaded { - await loadOrganizations() - } + let shouldLoadOrgs = authState.user?.username == username && !organizationsLoaded + async let msgs: Void = loadMessages() + async let orgs: Void = shouldLoadOrgs ? loadOrganizations() : () + _ = await (msgs, orgs) } .onChange(of: selectedTab) { _, tab in if tab == 1 && lists.isEmpty && listsError == nil { diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..e69de29 From ada56de2098ce39b463641d6520dff943ef81c8c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 18:59:08 -0700 Subject: [PATCH 06/12] Fixed social media issue. --- InterlinedList/Services/APIClient.swift | 3 +++ InterlinedList/Views/ComposeView.swift | 8 ++++---- InterlinedList/Views/LinkedIdentitiesView.swift | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 6d6377c..91b93f5 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -145,6 +145,9 @@ final class APIClient { let provider: String let providerUsername: String? let createdAt: String? + + /// Base provider name without any instance suffix (e.g. "mastodon:techhub.social" → "mastodon"). + var providerType: String { String(provider.prefix(while: { $0 != ":" })) } } func linkedIdentities() async throws -> [LinkedIdentity] { diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 87825be..3e76f2e 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -389,10 +389,10 @@ struct ComposeView: View { // MARK: - Cross-post controls - private var mastodonIdentities: [APIClient.LinkedIdentity] { allIdentities.filter { $0.provider == "mastodon" } } - private var hasBluesky: Bool { allIdentities.contains { $0.provider == "bluesky" } } - private var hasLinkedIn: Bool { allIdentities.contains { $0.provider == "linkedin" } } - private var hasTwitter: Bool { allIdentities.contains { $0.provider == "twitter" } } + private var mastodonIdentities: [APIClient.LinkedIdentity] { allIdentities.filter { $0.providerType == "mastodon" } } + private var hasBluesky: Bool { allIdentities.contains { $0.providerType == "bluesky" } } + private var hasLinkedIn: Bool { allIdentities.contains { $0.providerType == "linkedin" } } + private var hasTwitter: Bool { allIdentities.contains { $0.providerType == "twitter" } } private var hasMastodon: Bool { !mastodonIdentities.isEmpty } private var hasCrossPostTargets: Bool { hasBluesky || hasLinkedIn || hasTwitter || hasMastodon } diff --git a/InterlinedList/Views/LinkedIdentitiesView.swift b/InterlinedList/Views/LinkedIdentitiesView.swift index a80cfa4..26594f8 100644 --- a/InterlinedList/Views/LinkedIdentitiesView.swift +++ b/InterlinedList/Views/LinkedIdentitiesView.swift @@ -108,7 +108,7 @@ struct LinkedIdentitiesView: View { @ViewBuilder private func identityRow(_ identity: APIClient.LinkedIdentity) -> some View { HStack(spacing: 12) { - Image(systemName: OAuthProvider(rawValue: identity.provider)?.systemImageName ?? "link") + Image(systemName: OAuthProvider(rawValue: identity.providerType)?.systemImageName ?? "link") .frame(width: 24) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { @@ -131,7 +131,7 @@ struct LinkedIdentitiesView: View { } private func displayName(for provider: String) -> String { - OAuthProvider(rawValue: provider)?.displayName ?? provider.capitalized + OAuthProvider(rawValue: String(provider.prefix(while: { $0 != ":" })))?.displayName ?? provider.capitalized } private func load() async { From a6c7ea738aad5ab0f95e3673ce71b88994470705 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 6 Jul 2026 23:57:39 -0700 Subject: [PATCH 07/12] Fix Bluesky cross-post decode crash when server omits platform field. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrossPostResult.platform and .success were non-optional, so a server response missing the platform key caused a DecodingError that failed the whole CreateMessageResponse decode — showing "Connection failed" even though the post succeeded. Made both fields optional with safe fallbacks. Also logs the real error in the generic catch block and surfaces the server error string in the cross-post status toast. Co-Authored-By: Claude Sonnet 4.6 --- InterlinedList/Models/Message.swift | 7 ++++--- InterlinedList/Views/ComposeView.swift | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index e404510..d54286a 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -161,12 +161,13 @@ struct CreateMessageBody: Encodable { /// One platform's result after a cross-post attempt. Surfaced in a post-publish toast. /// Best-effort: the create response may or may not include this depending on deployment. +/// All fields are optional because the server shape is inconsistent across deployments. struct CrossPostResult: Codable, Identifiable { - let platform: String - let success: Bool + let platform: String? + let success: Bool? let error: String? - var id: String { platform } + var id: String { platform ?? error ?? UUID().uuidString } } struct CreateMessageResponse: Codable { diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 3e76f2e..286d830 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -5,6 +5,9 @@ import SwiftUI import PhotosUI +import OSLog + +private let composeLog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "InterlinedList", category: "ComposeView") /// Fallback when user's maxMessageLength is not available (matches API default). private let defaultMaxMessageLength = 666 @@ -227,8 +230,17 @@ struct ComposeView: View { else if scheduledDate != nil { base = "Your message has been scheduled." } else { base = "Your message was posted." } if !lastCrossPostResults.isEmpty { - let summary = lastCrossPostResults.map { r in - "\(r.platform.capitalized) \(r.success ? "✓" : "✗")" + let summary = lastCrossPostResults.map { r -> String in + let succeeded = r.success ?? false + let label = r.platform?.capitalized ?? "Cross-post" + let status = succeeded ? "✓" : "✗" + let detail: String + if !succeeded, let msg = r.error, !msg.isEmpty { + detail = " (\(msg))" + } else { + detail = "" + } + return "\(label) \(status)\(detail)" }.joined(separator: " · ") return base + "\n" + summary } @@ -585,6 +597,7 @@ struct ComposeView: View { } catch APIError.status(403) { errorMessage = "You may need to verify your email before posting." } catch { + composeLog.error("postMessage failed: \(error)") errorMessage = "Connection failed. Please try again." } } From b80f00ec934edca260cb185aec7fd70819f09da8 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 7 Jul 2026 00:00:26 -0700 Subject: [PATCH 08/12] Added the claude. --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f886d6e..371a572 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -78,7 +78,8 @@ "Bash(echo \"---exit $?\")", "Bash(git remote *)", "Bash(sort -t: -k1,1 -k2,2n)", - "Bash(gh run *)" + "Bash(gh run *)", + "Bash(git *)" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" From c74df5f158f84ae73858420f48ddfdc3175fa520 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 7 Jul 2026 18:05:38 -0700 Subject: [PATCH 09/12] Moving toward feature complete. --- .claude/settings.local.json | 19 +++- App-Store-Deployment.md | 11 ++- InterlinedList.xcodeproj/project.pbxproj | 4 + InterlinedList/Models/ListWatcher.swift | 1 + InterlinedList/Services/APIClient.swift | 29 ++++-- .../Views/PublicListDetailView.swift | 4 +- .../APIClientGapPhasesTests.swift | 7 +- .../APIClientModerationTests.swift | 88 +++++++++++++++++ .../ModelTests/ModerationModelTests.swift | 99 +++++++++++++++++++ PROMPT.md | 0 blocker-prompts.md | 76 ++++++++++---- 11 files changed, 303 insertions(+), 35 deletions(-) create mode 100644 InterlinedListTests/ModelTests/ModerationModelTests.swift delete mode 100644 PROMPT.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 371a572..76d0594 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -79,7 +79,24 @@ "Bash(git remote *)", "Bash(sort -t: -k1,1 -k2,2n)", "Bash(gh run *)", - "Bash(git *)" + "Bash(git *)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" https://interlinedlist.com/terms)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" https://interlinedlist.com/guidelines)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" https://interlinedlist.com/community)", + "Bash(curl -s -X POST https://interlinedlist.com/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"messenger@interlinedlist.com\",\"password\":\"metal69!\"}')", + "Bash(curl -s -i -X POST https://interlinedlist.com/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"messenger@interlinedlist.com\",\"password\":\"metal69!\"}')", + "Bash(curl -s -X POST https://interlinedlist.com/api/auth/sync-token -H 'Content-Type: application/json' -d '{\"email\":\"messenger@interlinedlist.com\",\"password\":\"metal69!\"}')", + "Bash(curl -s https://interlinedlist.com/api/user -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s https://interlinedlist.com/api/messages?limit=2 -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s https://interlinedlist.com/api/messages?limit=5 -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s -o /tmp/blocks.json -w '%{http_code}' https://interlinedlist.com/api/user/blocks -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s -o /tmp/identities.json -w '%{http_code}' https://interlinedlist.com/api/user/identities -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s -o /tmp/push.json -w '%{http_code}' -X POST https://interlinedlist.com/api/push/register -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb' -H 'Content-Type: application/json' -d '{\"token\":\"aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233\",\"platform\":\"ios\"}')", + "Bash(curl -s https://interlinedlist.com/api/user/organizations -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s -o /tmp/avatar.json -w '%{http_code}' -X POST https://interlinedlist.com/api/user/avatar/from-url -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb' -H 'Content-Type: application/json' -d '{\"url\":\"https://example.com/fake.jpg\"}')", + "Bash(curl -s https://interlinedlist.com/api/messages?limit=20 -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')", + "Bash(curl -s -o /tmp/msg_org.json -w '%{http_code}' -X POST https://interlinedlist.com/api/messages -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb' -H 'Content-Type: application/json' -d '{\"organizationId\":\"00000000-0000-0000-0000-000000000000\"}')", + "Bash(curl -s https://interlinedlist.com/api/folders -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" diff --git a/App-Store-Deployment.md b/App-Store-Deployment.md index a6d837c..436a16e 100644 --- a/App-Store-Deployment.md +++ b/App-Store-Deployment.md @@ -49,6 +49,13 @@ The following phases are complete and in the current build: **Shipped since last update (2026-07-05):** Phase 0.5 (Info.plist: arm64, ITSAppUsesNonExemptEncryption), Phase 14 (UGC safety: report message/user, block/unblock user, mute, terms acceptance on register, blocked users in settings), Phase 9 (push notifications: PushService, APNs delegate, register/unregister device token). +**Shipped 2026-07-07 (backend sync + iOS follow-up):** +- `GET /api/user/organizations` now accepts Bearer tokens → `OrganizationListView` unblocked; no iOS workaround needed. +- `GET /api/lists/{id}/watchers/me` now returns `role` field → `WatchingResponse` model updated; `isWatchingList` returns full response. +- Self-watch via `POST /api/lists/{id}/watchers` no longer requires `userId` in body → `watchSelf(listId:)` added; `PublicListDetailView` uses it. +- Avatar update flow no longer issues trailing `GET /api/user`; uses `PATCH /api/user/update` response instead. +- Moderation unit tests expanded (bearer token + error handling tests for all 8 methods); `ModerationModelTests` added with Codable decode coverage for `BlockedUser`, `MutedUser`, and their response wrappers. + --- ## 1. Feature Completion — What Must Ship Before Submission @@ -86,7 +93,7 @@ iOS work (after backend is confirmed): - [x] Surface Terms + Community Guidelines links in `SettingsView` → About - [x] New files: `Views/ReportSheet.swift`, `Views/BlockedUsersView.swift`, `Models/Moderation.swift`, `Services/PushService.swift` - [x] New `APIClient` methods: `reportMessage`, `reportUser`, `blockUser`, `unblockUser`, `blockedUsers`, `muteUser`, `unmuteUser`, `mutedUsers` -- [ ] Unit tests (MockURLSession) for all new API methods; decoding tests for new models +- [x] Unit tests (MockURLSession) for all new API methods; decoding tests for new models - [x] `#Preview` for all new views; `.accessibilityLabel` on all new controls #### Phase 0.5 — Info.plist Hygiene `Tiny` ⛔ @@ -407,7 +414,7 @@ Copy this and tick it off just before submitting. **Feature gates** - [x] Phase 14 — UGC safety shipped (report, block, mute, terms gate) - [x] Phase 0.5 — Info.plist hygiene done (`ITSAppUsesNonExemptEncryption`, `arm64`, icon) -- [x] Phase 9 — Push notifications wired (PushService, register/unregister); APNs capability still needs adding in Xcode +- [x] Phase 9 — Push notifications wired (PushService, register/unregister, `aps-environment: development` entitlement in place; Xcode auto-signing upgrades to production on archive) **Accounts & credentials** - [ ] Apple Developer Program membership active; agreements accepted in ASC diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index a6fa370..2131fd6 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -112,6 +112,7 @@ T1E5T1E5T1E5P023 /* APIClientDeleteAccountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */; }; T1E5T1E5T1E5P025 /* OrganizationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */; }; T1E5T1E5T1E5P027 /* LinkedIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */; }; + T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P030 /* ModerationModelTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -235,6 +236,7 @@ T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDeleteAccountTests.swift; sourceTree = ""; }; T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizationModelTests.swift; sourceTree = ""; }; T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentityModelTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P030 /* ModerationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModerationModelTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -436,6 +438,7 @@ T1E5T1E5T1E5P008 /* ListSchemaDraftTests.swift */, T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */, T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */, + T1E5T1E5T1E5P030 /* ModerationModelTests.swift */, 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, ); path = ModelTests; @@ -653,6 +656,7 @@ T1E5T1E5T1E5P023 /* APIClientDeleteAccountTests.swift in Sources */, T1E5T1E5T1E5P025 /* OrganizationModelTests.swift in Sources */, T1E5T1E5T1E5P027 /* LinkedIdentityModelTests.swift in Sources */, + T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */, 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */, 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */, E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */, diff --git a/InterlinedList/Models/ListWatcher.swift b/InterlinedList/Models/ListWatcher.swift index f3fd2ba..432d196 100644 --- a/InterlinedList/Models/ListWatcher.swift +++ b/InterlinedList/Models/ListWatcher.swift @@ -94,4 +94,5 @@ struct WatcherCandidatesResponse: Decodable { struct WatchingResponse: Decodable { let watching: Bool + let role: String? } diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 91b93f5..f6958bd 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -209,14 +209,25 @@ final class APIClient { request.httpBody = body let (responseData, response) = try await session.data(for: request) try checkResponse(data: responseData, response: response) - // Endpoint returns { url } only; refresh the user object to satisfy the signature. + struct UploadResp: Decodable { let url: String? } + if let avatarUrl = (try? decoder.decode(UploadResp.self, from: responseData))?.url { + return try await applyAvatarUrl(avatarUrl) + } return try await currentUser() } - func setAvatarFromURL(_ url: String) async throws -> User { + func setAvatarFromURL(_ avatarUrl: String) async throws -> User { struct Body: Encodable { let url: String } struct Response: Decodable { let url: String? } - let _: Response = try await post("/api/user/avatar/from-url", body: Body(url: url)) + let resp: Response = try await post("/api/user/avatar/from-url", body: Body(url: avatarUrl)) + return try await applyAvatarUrl(resp.url ?? avatarUrl) + } + + private func applyAvatarUrl(_ url: String) async throws -> User { + struct Body: Encodable { let avatar: String } + struct Resp: Decodable { let user: User? } + let wrapped: Resp = try await post("/api/user/update", body: Body(avatar: url)) + if let user = wrapped.user { return user } return try await currentUser() } @@ -910,10 +921,16 @@ final class APIClient { return response.watchers } - func isWatchingList(listId: String) async throws -> Bool { + func isWatchingList(listId: String) async throws -> WatchingResponse { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + return try await get("/api/lists/\(encoded)/watchers/me") + } + + func watchSelf(listId: String) async throws { + struct Empty: Encodable {} + struct Response: Decodable { let watching: Bool? } let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId - let response: WatchingResponse = try await get("/api/lists/\(encoded)/watchers/me") - return response.watching + let _: Response = try await postCamel("/api/lists/\(encoded)/watchers", body: Empty()) } func searchWatcherCandidates(listId: String, limit: Int = 20, offset: Int = 0) async throws -> [WatcherCandidate] { diff --git a/InterlinedList/Views/PublicListDetailView.swift b/InterlinedList/Views/PublicListDetailView.swift index 95b57b7..fa746f4 100644 --- a/InterlinedList/Views/PublicListDetailView.swift +++ b/InterlinedList/Views/PublicListDetailView.swift @@ -124,7 +124,7 @@ struct PublicListDetailView: View { self.error = "Could not load this list." } // Watch status is best-effort and independent of the list body. - isWatching = try? await APIClient.shared.isWatchingList(listId: listId) + isWatching = try? await APIClient.shared.isWatchingList(listId: listId).watching } private func toggleWatch() async { @@ -137,7 +137,7 @@ struct PublicListDetailView: View { try await APIClient.shared.removeWatcher(listId: listId, userId: userId) isWatching = false } else { - _ = try await APIClient.shared.addWatcher(listId: listId, userId: userId, role: .watcher) + try await APIClient.shared.watchSelf(listId: listId) isWatching = true } } catch APIError.status(401) { diff --git a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift index 00bae55..7135638 100644 --- a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift @@ -63,10 +63,11 @@ final class APIClientGapPhasesTests: XCTestCase { } func test_isWatchingList_decodesBool() async throws { - session.stub(json: #"{"watching":true}"#) - let watching = try await sut.isWatchingList(listId: "l1") + session.stub(json: #"{"watching":true,"role":"watcher"}"#) + let response = try await sut.isWatchingList(listId: "l1") XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers/me") == true) - XCTAssertTrue(watching) + XCTAssertTrue(response.watching) + XCTAssertEqual(response.role, "watcher") } func test_addWatcher_postsUserIdAndRole() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift index 9b00ccb..0ebf908 100644 --- a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift @@ -54,6 +54,30 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/report") } + func test_reportUser_sendsBearerToken() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportUser(id: "user-id", reason: .spam, detail: nil) + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_reportUser_bodyContainsReason() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportUser(id: "user-id", reason: .misinformation, detail: "test detail") + let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + XCTAssertEqual(json?["reason"] as? String, "misinformation") + } + + func test_reportUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.reportUser(id: "user-id", reason: .spam, detail: nil) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: blockUser() func test_blockUser_sendsPostToCorrectPath() async throws { @@ -68,6 +92,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.httpMethod, "POST") } + func test_blockUser_sendsBearerToken() async throws { + session.stub(json: #"{"blocked":true}"#) + try await sut.blockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_blockUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.blockUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: unblockUser() func test_unblockUser_sendsDeleteToCorrectPath() async throws { @@ -77,6 +117,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/block") } + func test_unblockUser_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unblockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_unblockUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.unblockUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: blockedUsers() func test_blockedUsers_sendsGetRequest() async throws { @@ -103,6 +159,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/mute") } + func test_muteUser_sendsBearerToken() async throws { + session.stub(json: #"{"muted":true}"#) + try await sut.muteUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_muteUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.muteUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: unmuteUser() func test_unmuteUser_sendsDeleteRequest() async throws { @@ -112,6 +184,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/mute") } + func test_unmuteUser_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unmuteUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_unmuteUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.unmuteUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: mutedUsers() func test_mutedUsers_decodesResponse() async throws { diff --git a/InterlinedListTests/ModelTests/ModerationModelTests.swift b/InterlinedListTests/ModelTests/ModerationModelTests.swift new file mode 100644 index 0000000..9ccca8d --- /dev/null +++ b/InterlinedListTests/ModelTests/ModerationModelTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import InterlinedList + +final class ModerationModelTests: XCTestCase { + private let decoder = JSONDecoder() + + // MARK: - BlockedUser + + func test_blockedUser_decodes_fullObject() throws { + let json = #"{"id":"u1","username":"alice","displayName":"Alice Smith","avatar":"https://example.com/a.jpg"}"# + let user = try decoder.decode(BlockedUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.id, "u1") + XCTAssertEqual(user.username, "alice") + XCTAssertEqual(user.displayName, "Alice Smith") + XCTAssertEqual(user.avatar, "https://example.com/a.jpg") + } + + func test_blockedUser_decodes_nullOptionalFields() throws { + let json = #"{"id":"u2","username":"bob","displayName":null,"avatar":null}"# + let user = try decoder.decode(BlockedUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.id, "u2") + XCTAssertEqual(user.username, "bob") + XCTAssertNil(user.displayName) + XCTAssertNil(user.avatar) + } + + // MARK: - BlockedUsersResponse + + func test_blockedUsersResponse_decodes_emptyList() throws { + let json = #"{"blockedUsers":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"# + let resp = try decoder.decode(BlockedUsersResponse.self, from: Data(json.utf8)) + XCTAssertTrue(resp.blockedUsers.isEmpty) + XCTAssertEqual(resp.pagination?.total, 0) + XCTAssertEqual(resp.pagination?.hasMore, false) + } + + func test_blockedUsersResponse_decodes_multipleUsers() throws { + let json = #""" + { + "blockedUsers": [ + {"id":"u1","username":"alice","displayName":"Alice","avatar":null}, + {"id":"u2","username":"bob","displayName":null,"avatar":null} + ], + "pagination": {"total":2,"limit":20,"offset":0,"hasMore":false} + } + """# + let resp = try decoder.decode(BlockedUsersResponse.self, from: Data(json.utf8)) + XCTAssertEqual(resp.blockedUsers.count, 2) + XCTAssertEqual(resp.blockedUsers[0].id, "u1") + XCTAssertEqual(resp.blockedUsers[1].username, "bob") + } + + func test_blockedUsersResponse_decodes_nullPagination() throws { + let json = #"{"blockedUsers":[],"pagination":null}"# + let resp = try decoder.decode(BlockedUsersResponse.self, from: Data(json.utf8)) + XCTAssertNil(resp.pagination) + } + + // MARK: - MutedUser + + func test_mutedUser_decodes_fullObject() throws { + let json = #"{"id":"u3","username":"carol","displayName":"Carol","avatar":"https://example.com/c.jpg"}"# + let user = try decoder.decode(MutedUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.id, "u3") + XCTAssertEqual(user.username, "carol") + XCTAssertEqual(user.displayName, "Carol") + XCTAssertEqual(user.avatar, "https://example.com/c.jpg") + } + + func test_mutedUser_decodes_nullOptionalFields() throws { + let json = #"{"id":"u4","username":"dave","displayName":null,"avatar":null}"# + let user = try decoder.decode(MutedUser.self, from: Data(json.utf8)) + XCTAssertNil(user.displayName) + XCTAssertNil(user.avatar) + } + + // MARK: - MutedUsersResponse + + func test_mutedUsersResponse_decodes_emptyList() throws { + let json = #"{"mutedUsers":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"# + let resp = try decoder.decode(MutedUsersResponse.self, from: Data(json.utf8)) + XCTAssertTrue(resp.mutedUsers.isEmpty) + XCTAssertEqual(resp.pagination?.total, 0) + } + + func test_mutedUsersResponse_decodes_multipleUsers() throws { + let json = #""" + { + "mutedUsers": [ + {"id":"u5","username":"eve","displayName":"Eve","avatar":null} + ], + "pagination": {"total":1,"limit":20,"offset":0,"hasMore":false} + } + """# + let resp = try decoder.decode(MutedUsersResponse.self, from: Data(json.utf8)) + XCTAssertEqual(resp.mutedUsers.count, 1) + XCTAssertEqual(resp.mutedUsers[0].displayName, "Eve") + } +} diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index e69de29..0000000 diff --git a/blocker-prompts.md b/blocker-prompts.md index a61eaff..18b59f6 100644 --- a/blocker-prompts.md +++ b/blocker-prompts.md @@ -14,9 +14,18 @@ the API openapi.json and `/help/api/*` pages for current route definitions. --- +## Audit log + +| Date | Summary | +|---|---| +| 2026-07-02 | Initial audit. All items below identified as outstanding. | +| 2026-07-07 | Re-audit. All hard blockers and v1 targets confirmed resolved. One new Bearer-auth gap found on `GET /api/user/organizations`. See Prompt 8 (updated). | + +--- + ## HARD BLOCKERS -### 1. Implement content reporting endpoints +### 1. Implement content reporting endpoints ✅ RESOLVED 2026-07-07 **Why:** Apple Guideline 1.2 requires every UGC/social app to let users report objectionable content. The iOS app **will be rejected** without this. @@ -54,7 +63,7 @@ Response: { "reported": true } --- -### 2. Implement user block / unblock endpoints +### 2. Implement user block / unblock endpoints ✅ RESOLVED 2026-07-07 **Why:** Apple Guideline 1.2 requires a mechanism to block abusive users. Hard gate on App Store submission. @@ -98,7 +107,7 @@ Response 200: --- -### 3. Implement user mute endpoint (recommended, not strictly required) +### 3. Implement user mute endpoint (recommended, not strictly required) ✅ RESOLVED 2026-07-07 **Why:** A mute (softer than a block) is not strictly required by Apple 1.2, but if a server-side mute exists it prevents re-surfacing muted content after @@ -120,7 +129,7 @@ iOS client will fall back to local-only muting. --- -### 4. Publish a Community Guidelines / EULA page +### 4. Publish a Community Guidelines / EULA page ✅ RESOLVED 2026-07-07 **Why:** Apple 1.2 requires UGC apps to display a zero-tolerance community agreement that users accept at registration. The iOS `RegisterView` needs a @@ -146,7 +155,7 @@ EULA decision) to the iOS team so they can wire `RegisterView`. ## v1 TARGETS -### 5. Confirm and document push notification endpoint contracts +### 5. Confirm and document push notification endpoint contracts ✅ RESOLVED 2026-07-07 **Why:** Phase 9 (APNs push notifications) ships in v1. The endpoints reportedly exist (`POST /api/push/register`, `DELETE /api/push/unregister`) @@ -189,12 +198,16 @@ Expected response: { "unregistered": true } --- -### 6. Confirm or add org-author field to create-message endpoint +### 6. Confirm or add org-author field to create-message endpoint ✅ CONFIRMED 2026-07-07 **Why:** Phase 15 (post on behalf of an organization) requires the create-message endpoint to accept the posting organization as the author. This field has not been confirmed to exist. +**Confirmed 2026-07-07:** `organizationId` is recognized — a probe with a non-member org +UUID returned `403 Forbidden: you must be an owner or admin of this organization`, confirming +the field is parsed and the auth check is enforced. No further backend work needed here. + **Confirm or add:** `POST /api/messages` (camelCase body): @@ -217,7 +230,7 @@ team can formally defer Phase 15. --- -### 7. Make GET /api/user/identities accept Bearer tokens +### 7. Make GET /api/user/identities accept Bearer tokens ✅ RESOLVED 2026-07-07 **Why:** This endpoint currently returns `401 Unauthorized` for a valid Bearer token (confirmed 2026-07-02). It accepts session cookies only. Because @@ -263,16 +276,34 @@ there eliminates the extra round-trip. These are not App Store blockers but they each require a server change and are worth fixing before v1 if the effort is low. -### 8. Add userRole and memberCount to org list responses +### 8. Make GET /api/user/organizations accept Bearer tokens ⚠️ ACTIVE — found 2026-07-07 + +**Why:** `GET /api/user/organizations` returns `{"error":"Unauthorized"}` when called with a +valid Bearer token. Session-cookie auth works fine. Because the iOS app is Bearer-only, the +org list in the app is permanently empty — the same class of bug that was just fixed for +`GET /api/user/identities` (Prompt 7). -**Why:** `GET /api/user/organizations` and `GET /api/organizations` don't -include `userRole`, so `OrganizationDetailView` must make a second round-trip -to `GET /api/organizations/{id}` just to learn whether the caller is -owner/admin/member and therefore whether to show edit/delete actions. +**Confirmed 2026-07-07:** When called with a session cookie the endpoint already returns +`userRole` and `memberCount` per org — so the response shape (the original ask) is already +correct; only the auth layer needs fixing. -**Fix:** Add per-item fields to both list endpoints: +**Fix:** Accept a valid Bearer token on `GET /api/user/organizations`, the same way +`GET /api/user` and (now) `GET /api/user/identities` do. + +Confirmed response shape (no change needed): ```json -{ "id": "…", "name": "…", "userRole": "owner" | "admin" | "member", "memberCount": 12, … } +{ + "organizations": [ + { + "id": "…", + "name": "…", + "slug": "…", + "userRole": "owner" | "admin" | "member", + "memberCount": 3, + … + } + ] +} ``` --- @@ -449,17 +480,20 @@ Conflicted docs should be returned so the iOS client can show a banner. ## Confirmed gaps for reference -These were live issues as of the last audit (2026-07-02): +Last audit: 2026-07-07. | Ref | Gap | iOS workaround | Status | |---|---|---|---| -| D0 | `GET /api/user/identities` returns 401 for Bearer tokens | Mastodon picker always empty | **Active — see Prompt 7** | +| D0 | `GET /api/user/identities` returns 401 for Bearer tokens | Mastodon picker always empty | ✅ Resolved 2026-07-07 | +| D0b | `GET /api/user/organizations` returns 401 for Bearer tokens | Org list always empty | **Active — see Prompt 8** | | B4 | `/api/github/*` requires session cookie, not Bearer | GitHub integration deferred | Deferred — Prompt 15 | | B6 | No tag discovery endpoints | Tag explorer/autocomplete deferred | Deferred — Prompt 16 | | B8 | No realtime (WebSocket/SSE) | Poll-only | Deferred — Prompt 17 | -| B10 | Moderation endpoints unverified | Phase 14 blocked | **Hard blocker — Prompts 1–4** | -| C2 | Watcher list pagination has `total` at top level, not in `pagination` | Special-cased decoder | Quality — Prompt 9 | -| C3 | Self-watch `userId` requirement unclear | iOS sends own userId | Quality — Prompt 13 | -| D1 | Cross-post failure shape unconfirmed | Toast shows only successes | Quality — Prompt 14 | +| B10 | Moderation (report/block/mute) endpoints missing | Phase 14 blocked | ✅ Resolved 2026-07-07 | +| B10b | Community guidelines page missing | RegisterView terms link broken | ✅ Resolved 2026-07-07 | +| B11 | Push register/unregister contracts unconfirmed | PushService not built | ✅ Resolved 2026-07-07 | +| C2 | Watcher list pagination has `total` at top level, not in `pagination` | Special-cased decoder | Quality — Prompt 9 (unverified) | +| C3 | Self-watch `userId` requirement unclear | iOS sends own userId | Quality — Prompt 13 (unverified) | +| D1 | Cross-post failure shape unconfirmed | Toast shows only successes | Quality — Prompt 14 (unverified) | | D2 | `linkedInTargets[].kind` vocabulary undocumented | Boolean-only cross-post | Deferred with Phase 18 | -| E | Avatar endpoints don't return updated user | Extra `GET /api/user` after upload | Quality — Prompt 11 | +| E | Avatar endpoints don't return updated user | Extra `GET /api/user` after upload | Quality — Prompt 11 (unverified) | From d32261eb1e1524dc7abffd52ec058879ae9e1162 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 8 Jul 2026 13:40:02 -0700 Subject: [PATCH 10/12] updated checklist. --- App-Store-Deployment-Checklist.md | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 App-Store-Deployment-Checklist.md diff --git a/App-Store-Deployment-Checklist.md b/App-Store-Deployment-Checklist.md new file mode 100644 index 0000000..cbb1c2a --- /dev/null +++ b/App-Store-Deployment-Checklist.md @@ -0,0 +1,109 @@ +# App Store Deployment — Pre-Flight Checklist + +Last updated: 2026-07-08 + +--- + +## Feature gates + +- [x] Phase 0.5 — Info.plist hygiene done (`ITSAppUsesNonExemptEncryption`, `arm64`, icon) +- [x] Phase 9 — Push notifications wired (PushService, register/unregister, `aps-environment: development` entitlement in place; Xcode auto-signing upgrades to production on archive) +- [x] Phase 14 — UGC safety shipped (report message/user, block/unblock, mute, terms gate on register, blocked users in settings) +- [x] Unit tests — All 8 moderation API methods have bearer token + 401 error tests; `ModerationModelTests` covers `BlockedUser`/`MutedUser` Codable decoding + +--- + +## Accounts & credentials + +| Item | Status | Notes | +|---|---|---| +| Apple Developer Program membership active; agreements accepted in ASC | ☐ | $99/yr — verify at developer.apple.com | +| Your role on team `BJA9558E4B` is Admin or App Manager | ☐ | Check in App Store Connect | +| APNs Auth Key (.p8) created, Key ID recorded, handed to backend | ☐ | Portal → Certificates, IDs & Profiles → Keys → enable APNs; one-time download | +| Demo reviewer account registered and confirmed working on production | ☐ | Register at interlinedlist.com with email/password; keep creds in password manager | +| Privacy Policy URL live | ✅ | `https://interlinedlist.com/privacy` → 200 (verified 2026-07-07) | +| Support URL live | ✅ | `https://interlinedlist.com/help` → 307 redirect (acceptable for ASC) | +| Community Guidelines / EULA URL live and linked from `RegisterView` | ✅ | `https://interlinedlist.com/terms` → 200 (verified 2026-07-07) | + +--- + +## Xcode project + +| Item | Status | Notes | +|---|---|---| +| App ID `com.interlinedlist.app` registered in portal with Push Notifications enabled | ☐ | Portal → Identifiers → App IDs | +| Automatic signing, team `BJA9558E4B`, archive signs cleanly | ☐ | Target → Signing & Capabilities | +| No unused capabilities or entitlements | ☐ | Only `aps-environment` is present | +| Build number incremented from any prior upload | ☐ | `agvtool next-version -all` | + +--- + +## App Store Connect record + +| Item | Status | Notes | +|---|---|---| +| App record created (name: InterlinedList, bundle ID, SKU: `interlinedlist-ios`, Full access) | ☐ | appstoreconnect.apple.com → Apps → "+" | +| Free pricing, all territories selected | ☐ | Pricing and Availability tab | +| Primary category: Social Networking | ☐ | App Information tab | +| Privacy Policy URL entered | ☐ | `https://interlinedlist.com/privacy` | +| Support URL entered | ☐ | `https://interlinedlist.com/help` | +| Age rating questionnaire completed | ☐ | Fill after Phase 14 confirmed — expected 17+ | + +--- + +## Assets + +| Item | Status | Notes | +|---|---|---| +| App icon: no empty wells, no alpha channel (verified in Xcode asset catalog) | ☐ | `InterlinedList/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png` | +| Screenshots: 6.9" set uploaded (iPhone 16 Pro Max, 1320 × 2868 px) | ☐ | Minimum screens: Feed, Compose, Lists, Profile, Settings | +| Screenshots: 6.5" set uploaded (iPhone 15 Plus, 1242 × 2688 px) | ☐ | Same minimum screens | +| App name (≤30 chars): "InterlinedList" | ☐ | Verify uniqueness in ASC at record creation | +| Subtitle (≤30 chars) | ☐ | e.g. "Social lists, shared" | +| Description (≤4,000 chars) | ☐ | | +| Keywords (≤100 chars total, comma-separated) | ☐ | | +| Promotional text (≤170 chars) | ☐ | Changeable without a new submission | +| What's New: "Initial release." | ☐ | | +| App Privacy nutrition label completed and saved in ASC | ☐ | See §4 in `App-Store-Deployment.md` for declared data types | + +--- + +## Upload & review + +| Item | Status | Notes | +|---|---|---| +| Build archived (Release, Any iOS Device / arm64) | ☐ | Product → Archive in Xcode | +| Build uploaded and processed (visible in TestFlight) | ☐ | Distribute App → App Store Connect → Upload | +| TestFlight smoke-test passed on a real device | ☐ | See smoke-test checklist below | +| Review notes written | ☐ | See review notes template below | +| Submit for Review | ☐ | | + +### Smoke-test checklist (real device, before submitting) + +- [ ] Email/password login and registration +- [ ] OAuth (at least one provider — Mastodon or Bluesky) +- [ ] Compose + post (text, image) +- [ ] Feed scroll, dig/undig, reply +- [ ] Lists and Documents CRUD +- [ ] Organizations list loads (Bearer auth fix shipped 2026-07-07) +- [ ] Deep-link callbacks (`interlinedlist://reset-password`, `interlinedlist://verify-email`) +- [ ] Push notification receipt and tap routing +- [ ] Settings, sign-out, delete-account flow +- [ ] Report a message (tap `...` on any post → Report) +- [ ] Block a user (tap `...` on any post or visit their profile → Block) + +### Review notes template + +``` +Demo login: + Email: + Password: + +Subscriptions are managed exclusively at interlinedlist.com. +There is no in-app purchase, paywall, or billing UI in this app. + +To delete the account: Profile → Edit Profile → Delete Account (double-confirm). + +To report content: tap the … menu on any post → Report. +To block a user: tap the … menu on any post or visit their profile → Block. +``` From 973eb6ae8e71574d8f1df92173ff1cd04ea624d0 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 8 Jul 2026 14:25:08 -0700 Subject: [PATCH 11/12] Fixing the top menu --- InterlinedList/Views/DocumentsView.swift | 21 ++++++++++++++++++++- InterlinedList/Views/ListsView.swift | 6 ++++++ InterlinedList/Views/UserProfileView.swift | 6 ++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index cfaf880..1c174ee 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -108,8 +108,14 @@ struct DocumentsView: View { } } label: { Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("New item") + .buttonStyle(.plain) } } .refreshable { await store.refreshDocuments() } @@ -335,8 +341,14 @@ private struct DocumentFolderView: View { } } label: { Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("New item in \(folder.name)") + .buttonStyle(.plain) } } .refreshable { await load() } @@ -541,7 +553,14 @@ private struct DocumentDetailView: View { Button("Delete", role: .destructive) { showDeleteConfirm = true } } label: { Image(systemName: "ellipsis.circle") - } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) + } + .accessibilityLabel("Document options") + .buttonStyle(.plain) } } .sheet(isPresented: $showEdit) { diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 151da47..3e020f0 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -118,8 +118,14 @@ struct ListsView: View { } } label: { Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("New item") + .buttonStyle(.plain) } @ViewBuilder diff --git a/InterlinedList/Views/UserProfileView.swift b/InterlinedList/Views/UserProfileView.swift index 299e0e8..f116889 100644 --- a/InterlinedList/Views/UserProfileView.swift +++ b/InterlinedList/Views/UserProfileView.swift @@ -88,8 +88,14 @@ struct UserProfileView: View { } } label: { Image(systemName: "ellipsis.circle") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("More options") + .buttonStyle(.plain) } } } From a2a1a5343c138c9eb7a27ec302e4f95e364551ed Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 8 Jul 2026 16:57:46 -0700 Subject: [PATCH 12/12] feat(lists): move Add Item button to toolbar beside Watchers Remove the inline "Add Item" section from the list body and promote it to a toolbar button (plus icon) placed at topBarTrailing, left of the existing Watchers button. Disabled state when schema is empty is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- InterlinedList/Views/ListsView.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 3e020f0..26a510c 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -503,15 +503,6 @@ struct ListDetailView: View { ) } } - Section { - Button { - showAddItem = true - } label: { - Label("Add Item", systemImage: "plus") - } - .disabled(schema.isEmpty) - .accessibilityLabel("Add item to list") - } Section { if connections.isEmpty { Text("No connections yet") @@ -556,6 +547,15 @@ struct ListDetailView: View { .navigationTitle(list.name) .navigationBarTitleDisplayMode(.large) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddItem = true + } label: { + Image(systemName: "plus") + } + .disabled(schema.isEmpty) + .accessibilityLabel("Add item to list") + } ToolbarItem(placement: .topBarTrailing) { Button { showWatchers = true