Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6adcaf8
docs(parity): rewrite the-gaps.md — iOS↔web gap analysis, plan, execu…
Adron Jul 31, 2026
6312d56
fix(api): correct HTTP verbs on 3 writes; add muted-users screen + li…
Adron Jul 31, 2026
9aea063
feat(dm): Direct Messages (G1) — inbox, threads, near-realtime polling
Adron Jul 31, 2026
103b4c3
feat: document templates (G3) + people search (G6)
Adron Jul 31, 2026
e889cc1
feat(compose): LinkedIn posting-target picker (G5)
Adron Jul 31, 2026
d8e7bb5
feat(sharing): tokenized share-links + document collaborators (G2)
Adron Jul 31, 2026
603c48c
feat(lists): GitHub-backed lists (G4)
Adron Jul 31, 2026
8affa8b
feat(settings): active-sessions management (G12)
Adron Jul 31, 2026
2a4c06d
build: register new source files in Xcode project
Adron Jul 31, 2026
967a779
feat(documents): offline read + delta pull (G9 slice 1)
Adron Aug 1, 2026
bde99a5
feat: content deep links + web-permalink share actions (G10)
Adron Aug 1, 2026
58f7765
fix: DM recipient navigation + follow-status decode + resource-aware …
Adron Aug 1, 2026
7f14087
ui(tabbar): move user-avatar menu to the far right
Adron Aug 1, 2026
bb50450
feat(documents): offline edit queue + push (G9 slice 2)
Adron Aug 2, 2026
5b7c5a7
Other bits
Adron Aug 2, 2026
ef6a7a6
feat(documents): conflict-copy resolution (G9 slice 3)
Adron Aug 2, 2026
7b684f0
docs: backend asks A1 (GitHub in-app OAuth) + A2 (Universal Links) spec
Adron Aug 3, 2026
40b5850
feat(editor): markdown format toolbar with H1–H6 dropdown and icon Wr…
Adron Aug 13, 2026
2ae9e20
feat(sharing): email share-invites for lists & documents + watcher se…
Adron Aug 13, 2026
bd92337
fix(lists): correct ListConnection field names to match backend
Adron Aug 13, 2026
3ac4040
feat(lists): file, move, and rename lists into folders (+ folderId mo…
Adron Aug 13, 2026
d0d427a
Almost there.
Adron Aug 15, 2026
dee5ec6
All the things
Adron Aug 16, 2026
7bef1b5
feat(auth): enable GitHub native OAuth + Associated Domains entitleme…
Adron Aug 3, 2026
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: 6 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@
"mcp__xcodebuildmcp__build_sim",
"Bash(grep -E \"\\\\.swift$\")",
"Bash(/usr/bin/env bash --version)",
"Bash(./run-iphone.sh)"
"Bash(./run-iphone.sh)",
"Bash(chmod +x /private/tmp/claude-501/-Users-adron-Codez-interlinedlist-ios/e4665075-8f75-4abb-9a63-f2a985cfcbab/scratchpad/extract_routes.sh)",
"Bash(bash /private/tmp/claude-501/-Users-adron-Codez-interlinedlist-ios/e4665075-8f75-4abb-9a63-f2a985cfcbab/scratchpad/extract_routes.sh)",
"mcp__xcodebuildmcp__session_set_defaults",
"Bash(echo \"exit=$?\")",
"Bash(awk '/export async function getUserLists/,/^}/' lib/lists/queries.ts)"
],
"additionalDirectories": [
"/Users/adron/Codez/interlinedlist-ios/.claude"
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ xcuserdata/
DerivedData/
build/

.env
.env
# Local QA smoke-test screenshot artifacts
QA/
192 changes: 192 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions InterlinedList/InterlinedList.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:interlinedlist.com</string>
</array>
</dict>
</plist>
146 changes: 118 additions & 28 deletions InterlinedList/InterlinedListApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,38 +46,50 @@ struct InterlinedListApp: App {
switch link {
case .resetPassword(let token):
ResetPasswordView(token: token)
case .userProfile(let username):
NavigationStack {
UserProfileView(username: username)
}
.environmentObject(authState)
.environmentObject(store)
case .message(let id):
MessageLinkView(messageId: id)
.environmentObject(authState)
.environmentObject(store)
case .document(let id):
DocumentLinkView(documentId: id)
.environmentObject(authState)
case .sharedDocument(let token):
SharedDocumentView(token: token)
.environmentObject(authState)
case .verifyEmail, .verifyEmailChange:
// These never present a sheet — they run an async side effect in
// handleDeepLink and are never assigned to pendingDeepLink.
EmptyView()
}
}

// A2: Universal Links (a tapped https://interlinedlist.com link opening the app
// directly). The Associated Domains entitlement (applinks:interlinedlist.com) is now
// in place and `parse` already accepts https permalinks, so onOpenURL will route them
// once the backend publishes apple-app-site-association and the Associated Domains
// capability is enabled for the App ID / provisioning profile.
private func handleDeepLink(_ url: URL) {
guard url.scheme == "interlinedlist" else { return }
// Token query items are read but never logged — they're sensitive bearer
// material handed off to KeychainService / OAuthCoordinator.
let host = url.host ?? ""
let path = url.path
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let token = components?.queryItems?.first(where: { $0.name == "token" })?.value

switch (host, path) {
case ("reset-password", _), ("", "/reset-password"):
if let token, !token.isEmpty {
router.pendingDeepLink = .resetPassword(token: token)
}
case ("verify-email", _), ("", "/verify-email"):
if let token, !token.isEmpty {
Task { await verifyEmail(token: token) }
}
case ("verify-email-change", _), ("", "/verify-email-change"):
if let token, !token.isEmpty {
Task { await verifyEmailChange(token: token) }
}
case ("oauth", _):
// ASWebAuthenticationSession captures the callback automatically; the
// app-level handler is a fallback for when the session has been torn
// down (rare; safe to ignore the token rather than re-exchange it).
break
default:
break
// OAuth callbacks are captured by ASWebAuthenticationSession itself; the
// app-level handler is a fallback for a torn-down session (safe to ignore).
if url.scheme == "interlinedlist" && (url.host == "oauth" || url.path.hasPrefix("/oauth")) {
return
}
// Token query items are read via AppDeepLink.parse but never logged — they're
// sensitive bearer material handed off to KeychainService / OAuthCoordinator.
guard let link = AppDeepLink.parse(url) else { return }
switch link {
case .verifyEmail(let token):
Task { await verifyEmail(token: token) }
case .verifyEmailChange(let token):
Task { await verifyEmailChange(token: token) }
case .resetPassword, .userProfile, .message, .document, .sharedDocument:
router.pendingDeepLink = link
}
}

Expand Down Expand Up @@ -124,10 +136,88 @@ struct InterlinedListApp: App {

enum AppDeepLink: Identifiable, Hashable {
case resetPassword(token: String)
case verifyEmail(token: String)
case verifyEmailChange(token: String)
case userProfile(username: String)
case message(id: String)
case document(id: String)
case sharedDocument(token: String)

var id: String {
switch self {
case .resetPassword(let token): return "reset:" + token
case .verifyEmail(let token): return "verify:" + token
case .verifyEmailChange(let token): return "verify-change:" + token
case .userProfile(let username): return "profile:" + username
case .message(let id): return "message:" + id
case .document(let id): return "document:" + id
case .sharedDocument(let token): return "shared-document:" + token
}
}

/// Parses both the custom scheme (`interlinedlist://…`, where the target is the
/// URL host or first path segment) and canonical web permalinks
/// (`https://interlinedlist.com/…` / `https://www.interlinedlist.com/…`). Content
/// links map to `.userProfile` / `.message`; auth links preserve their `?token`.
/// Returns nil for unknown targets or web hosts other than interlinedlist.com.
static func parse(_ url: URL) -> AppDeepLink? {
let scheme = url.scheme?.lowercased()
let host = url.host?.lowercased() ?? ""

let target: String
let segments: [String]
if scheme == "interlinedlist" {
let pathSegments = url.path.split(separator: "/").map(String.init)
// The custom scheme puts the target in either the host (interlinedlist://user/bob)
// or the first path segment (interlinedlist:///user/bob), so try the host first.
if host.isEmpty {
target = pathSegments.first ?? ""
segments = Array(pathSegments.dropFirst())
} else {
target = host
segments = pathSegments
}
} else if scheme == "https" || scheme == "http" {
guard host == "interlinedlist.com" || host == "www.interlinedlist.com" else { return nil }
let pathSegments = url.path.split(separator: "/").map(String.init)
target = pathSegments.first ?? ""
segments = Array(pathSegments.dropFirst())
} else {
return nil
}

let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "token" })?.value

switch target {
case "user":
guard let username = segments.first, !username.isEmpty else { return nil }
return .userProfile(username: username)
case "message":
guard let id = segments.first, !id.isEmpty else { return nil }
return .message(id: id)
case "documents":
// `/documents/shared/<token>` resolves a share link; `/documents/<id>`
// opens a document permalink. (List permalinks are not routed: a bare
// `/lists/<id>` carries no owner username, which the list-detail endpoint
// requires — see the-gaps.md G10 follow-ons.)
if segments.first == "shared" {
guard segments.count >= 2, !segments[1].isEmpty else { return nil }
return .sharedDocument(token: segments[1])
}
guard let id = segments.first, !id.isEmpty else { return nil }
return .document(id: id)
case "reset-password":
guard let token, !token.isEmpty else { return nil }
return .resetPassword(token: token)
case "verify-email":
guard let token, !token.isEmpty else { return nil }
return .verifyEmail(token: token)
case "verify-email-change":
guard let token, !token.isEmpty else { return nil }
return .verifyEmailChange(token: token)
default:
return nil
}
}
}
Expand Down
194 changes: 194 additions & 0 deletions InterlinedList/Models/DirectMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//
// DirectMessage.swift
// InterlinedList
//

import Foundation

/// A user you can direct-message (mutual-follow set). Same shape as `MessageUser`
/// but kept distinct so the DM surface can evolve independently.
struct DMUser: Codable, Identifiable, Hashable {
let id: String
let username: String
let displayName: String?
let avatar: String?

var displayNameOrUsername: String {
displayName?.isEmpty == false ? (displayName ?? username) : username
}
}

/// Pure recipient-search filtering, extracted so the picker's list logic can be
/// unit-tested without SwiftUI. A blank query returns everyone unchanged.
enum DMRecipientFilter {
static func matches(_ recipients: [DMUser], query: String) -> [DMUser] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return recipients }
let lower = trimmed.lowercased()
return recipients.filter {
$0.username.lowercased().contains(lower) ||
($0.displayName?.lowercased().contains(lower) ?? false)
}
}
}

/// A single direct message. `sender`/`recipient` may be absent on some payloads
/// (e.g. thread updates), so both are optional and the view falls back to ids.
struct DMMessage: Codable, Identifiable, Hashable {
let id: String
let pairKey: String?
let senderId: String
let recipientId: String
let body: String
let imageUrls: [String]
let createdAt: String
let readAt: String?
let sender: DMUser?
let recipient: DMUser?
let preview: String?

enum CodingKeys: String, CodingKey {
case id, pairKey, senderId, recipientId, body, imageUrls, createdAt, readAt, sender, recipient, preview
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
pairKey = try c.decodeIfPresent(String.self, forKey: .pairKey)
senderId = try c.decode(String.self, forKey: .senderId)
recipientId = try c.decode(String.self, forKey: .recipientId)
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
imageUrls = try c.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
readAt = try c.decodeIfPresent(String.self, forKey: .readAt)
sender = try c.decodeIfPresent(DMUser.self, forKey: .sender)
recipient = try c.decodeIfPresent(DMUser.self, forKey: .recipient)
preview = try c.decodeIfPresent(String.self, forKey: .preview)
}

init(
id: String,
pairKey: String? = nil,
senderId: String,
recipientId: String,
body: String,
imageUrls: [String] = [],
createdAt: String,
readAt: String? = nil,
sender: DMUser? = nil,
recipient: DMUser? = nil,
preview: String? = nil
) {
self.id = id
self.pairKey = pairKey
self.senderId = senderId
self.recipientId = recipientId
self.body = body
self.imageUrls = imageUrls
self.createdAt = createdAt
self.readAt = readAt
self.sender = sender
self.recipient = recipient
self.preview = preview
}

var isRead: Bool { readAt != nil }

/// The other party relative to `userId` (nil if that side isn't populated).
func otherParty(selfId: String?) -> DMUser? {
guard let selfId else { return sender ?? recipient }
return senderId == selfId ? recipient : sender
}
}

/// A conversation with one other user, returned by `GET /api/dm/thread/:username`.
struct DMThread: Codable {
let items: [DMMessage]
let olderCursor: String?
let isMutual: Bool
let isBlocked: Bool
let otherUser: DMUser

enum CodingKeys: String, CodingKey {
case items, olderCursor, isMutual, isBlocked, otherUser
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
items = try c.decodeIfPresent([DMMessage].self, forKey: .items) ?? []
olderCursor = try c.decodeIfPresent(String.self, forKey: .olderCursor)
isMutual = try c.decodeIfPresent(Bool.self, forKey: .isMutual) ?? false
isBlocked = try c.decodeIfPresent(Bool.self, forKey: .isBlocked) ?? false
otherUser = try c.decode(DMUser.self, forKey: .otherUser)
}

init(items: [DMMessage], olderCursor: String?, isMutual: Bool, isBlocked: Bool, otherUser: DMUser) {
self.items = items
self.olderCursor = olderCursor
self.isMutual = isMutual
self.isBlocked = isBlocked
self.otherUser = otherUser
}
}

/// One inbox/sent/deleted folder in the DM surface.
enum DMFolder: String, CaseIterable, Identifiable {
case inbox
case sent
case deleted

var id: String { rawValue }

var title: String {
switch self {
case .inbox: return "Inbox"
case .sent: return "Sent"
case .deleted: return "Deleted"
}
}
}

// MARK: - Response wrappers

struct DMListResponse: Codable {
let items: [DMMessage]
let nextCursor: String?

enum CodingKeys: String, CodingKey { case items, nextCursor }

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
items = try c.decodeIfPresent([DMMessage].self, forKey: .items) ?? []
nextCursor = try c.decodeIfPresent(String.self, forKey: .nextCursor)
}

init(items: [DMMessage], nextCursor: String?) {
self.items = items
self.nextCursor = nextCursor
}
}

struct DMMessageResponse: Codable {
let message: DMMessage
}

struct DMRecipientsResponse: Codable {
let recipients: [DMUser]

enum CodingKeys: String, CodingKey { case recipients }

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
recipients = try c.decodeIfPresent([DMUser].self, forKey: .recipients) ?? []
}

init(recipients: [DMUser]) { self.recipients = recipients }
}

struct DMUnreadCountResponse: Codable {
let count: Int
}

struct DMUpdatedResponse: Codable {
let updated: Int
}
Loading
Loading