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
12 changes: 8 additions & 4 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
46E2E2E80CBE246E84A5D78B /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF89EFAF63A9287656938C /* MarkdownView.swift */; };
47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1147B978D9E72695E4457454 /* Moderation.swift */; };
4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; };
518167A032AEB479714E123F /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42A3D2275060CA2FE1DFA5A /* SharedListView.swift */; };
55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; };
56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; };
5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; };
Expand All @@ -48,6 +47,7 @@
73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; };
79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; };
7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; };
7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADBF836F0B5BD6524776FE7 /* SharingView.swift */; };
874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; };
8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; };
945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; };
Expand Down Expand Up @@ -118,6 +118,7 @@
D0A1D0A1D0A1000B /* LinkedIdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */; };
D0A1D0A1D0A1000D /* OAuthSignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000E /* OAuthSignInButton.swift */; };
D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */; };
D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E7411CA219F3E8EB351BAB /* SharedListView.swift */; };
D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; };
DAC151BF93B6B223D43714F0 /* APIClientShareInvitesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */; };
DD652FF47D5509B976706914 /* DocumentSyncMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */; };
Expand Down Expand Up @@ -208,6 +209,7 @@
2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = "<group>"; };
2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
32C2904D262C0859FD38A830 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = "<group>"; };
38E7411CA219F3E8EB351BAB /* SharedListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedListView.swift; sourceTree = "<group>"; };
3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncMerge.swift; sourceTree = "<group>"; };
3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = "<group>"; };
44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientShareInvitesTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -287,6 +289,7 @@
B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientGapPhasesTests.swift; sourceTree = "<group>"; };
B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagDiscovery.swift; sourceTree = "<group>"; };
B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FindPeopleView.swift; sourceTree = "<group>"; };
BADBF836F0B5BD6524776FE7 /* SharingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharingView.swift; sourceTree = "<group>"; };
BD05B653363E3A794B1B1EE7 /* ShareLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLink.swift; sourceTree = "<group>"; };
BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflict.swift; sourceTree = "<group>"; };
C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrganizationsView.swift; sourceTree = "<group>"; };
Expand All @@ -302,7 +305,6 @@
D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailVerificationBanner.swift; sourceTree = "<group>"; };
D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentitiesView.swift; sourceTree = "<group>"; };
D0A1D0A1D0A1000E /* OAuthSignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSignInButton.swift; sourceTree = "<group>"; };
D42A3D2275060CA2FE1DFA5A /* SharedListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedListView.swift; sourceTree = "<group>"; };
D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutboxTests.swift; sourceTree = "<group>"; };
D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = "<group>"; };
DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInviteModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -527,7 +529,8 @@
06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */,
08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */,
03D6C33FD59101503195F14F /* SharedDocumentView.swift */,
D42A3D2275060CA2FE1DFA5A /* SharedListView.swift */,
38E7411CA219F3E8EB351BAB /* SharedListView.swift */,
BADBF836F0B5BD6524776FE7 /* SharingView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -823,7 +826,8 @@
C83E43066F7C0ABF783444CA /* SharedResolution.swift in Sources */,
FA145F808445802781845261 /* DocumentLinkView.swift in Sources */,
081D31E74FF9214FF1F7F185 /* SharedDocumentView.swift in Sources */,
518167A032AEB479714E123F /* SharedListView.swift in Sources */,
D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */,
7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
36 changes: 36 additions & 0 deletions InterlinedList/Models/ListWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,42 @@ enum WatcherRole: String, Codable, CaseIterable, Comparable {
}
}

/// Web-aligned label for a **per-person** access grant (collaborator/watcher).
var accessLabel: String {
switch self {
case .watcher: return "Read-only"
case .collaborator: return "Edit"
case .manager: return "Admin"
}
}

/// Web-aligned label for a **share-link** role (anonymous / claimable link).
var linkLabel: String {
switch self {
case .watcher: return "Viewer"
case .collaborator: return "Editor"
case .manager: return "Admin"
}
}

/// Per-person access role description, matching the web copy.
func accessDetail(for resourceNoun: String) -> String {
switch self {
case .watcher: return "Can view this \(resourceNoun)."
case .collaborator: return "Can edit the \(resourceNoun) content."
case .manager: return "Everything Edit can, plus settings and delete."
}
}

/// Share-link role description, matching the web copy.
var linkDetail: String {
switch self {
case .watcher: return "Anyone with the link can view (no account needed)."
case .collaborator: return "A signed-in visitor can claim edit access."
case .manager: return "A signed-in visitor can claim full admin access."
}
}

/// Collaborators and managers may edit row data.
var canEditRows: Bool { self >= .collaborator }
/// Only managers may edit the schema and manage watchers.
Expand Down
35 changes: 35 additions & 0 deletions InterlinedList/Models/ShareLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@ enum ShareResourceKind: String {
}
}

/// Expiry presets for share links and email invites, matching the web
/// (Never / 7 days / 30 days). The client resolves a preset to an ISO-8601
/// `expiresAt` the API accepts (`nil` for Never).
enum ShareExpiryPreset: String, CaseIterable, Identifiable {
case never
case week
case month

var id: String { rawValue }

var label: String {
switch self {
case .never: return "Never"
case .week: return "7 days"
case .month: return "30 days"
}
}

private var days: Int? {
switch self {
case .never: return nil
case .week: return 7
case .month: return 30
}
}

/// The ISO-8601 `expiresAt` this preset maps to, or `nil` for "Never".
func expiresAt(from now: Date = Date()) -> String? {
guard let days, let date = Calendar.current.date(byAdding: .day, value: days, to: now) else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
return iso.string(from: date)
}
}

/// A tokenized share-link for a list or document. Only the resource owner can
/// create, list, or revoke these. `role` maps to `WatcherRole`.
struct ShareLink: Identifiable, Codable {
Expand Down
12 changes: 6 additions & 6 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1205,21 +1205,21 @@ final class APIClient {
}

@discardableResult
func addWatcher(listId: String, userId: String, role: WatcherRole) async throws -> Bool {
struct Body: Encodable { let userId: String; let role: String }
func addWatcher(listId: String, userId: String, role: WatcherRole, notify: Bool = false) async throws -> Bool {
struct Body: Encodable { let userId: String; let role: String; let notify: Bool }
struct Response: Decodable { let watching: Bool? }
let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId
let response: Response = try await postCamel("/api/lists/\(encoded)/watchers", body: Body(userId: userId, role: role.rawValue))
let response: Response = try await postCamel("/api/lists/\(encoded)/watchers", body: Body(userId: userId, role: role.rawValue, notify: notify))
return response.watching ?? true
}

@discardableResult
func setWatcherRole(listId: String, userId: String, role: WatcherRole) async throws -> String {
struct Body: Encodable { let role: String }
func setWatcherRole(listId: String, userId: String, role: WatcherRole, notify: Bool = true) async throws -> String {
struct Body: Encodable { let role: String; let notify: Bool }
struct Response: Decodable { let role: String? }
let encodedList = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId
let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId
let response: Response = try await putCamel("/api/lists/\(encodedList)/watchers/\(encodedUser)", body: Body(role: role.rawValue))
let response: Response = try await putCamel("/api/lists/\(encodedList)/watchers/\(encodedUser)", body: Body(role: role.rawValue, notify: notify))
return response.role ?? role.rawValue
}

Expand Down
19 changes: 19 additions & 0 deletions InterlinedList/Services/ILWebURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ enum ILWebURL {
make("/documents/shared", token)
}

/// The canonical **public** URL for a resource, scoped to its owner's profile
/// (`/user/<owner>/lists|documents/<id>`) — the link shown when a list/document
/// is made public. Falls back to `nil` if owner/id are missing.
static func publicResource(kind: ShareResourceKind, ownerUsername: String, id: String) -> URL? {
guard !ownerUsername.isEmpty, !id.isEmpty,
let owner = ownerUsername.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let ident = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
return nil
}
return URL(string: "\(base)/user/\(owner)/\(kind.pathSegment)/\(ident)")
}

/// The authed (non-public) permalink for a resource (`/lists|documents/<id>`) —
/// the "Get link" copy for a per-person grant; works for private resources
/// because the grantee already has access.
static func resource(kind: ShareResourceKind, id: String) -> URL? {
kind == .documents ? document(id) : list(id)
}

private static func make(_ prefix: String, _ segment: String) -> URL? {
guard !segment.isEmpty,
let encoded = segment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
Expand Down
29 changes: 12 additions & 17 deletions InterlinedList/Views/DocumentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -636,9 +636,7 @@ private struct DocumentDetailView: View {
@State private var current: Document
@State private var showEdit = false
@State private var showDeleteConfirm = false
@State private var showShare = false
@State private var showInvites = false
@State private var showCollaborators = false
@State private var showSharing = false

init(document: Document, onUpdate: @escaping (Document) -> Void, onDelete: @escaping (String) -> Void) {
self.document = document
Expand Down Expand Up @@ -666,15 +664,13 @@ private struct DocumentDetailView: View {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Edit") { showEdit = true }
Button("Share") { showShare = true }
Button("Invite by email") { showInvites = true }
Button("Share") { showSharing = true }
if let url = ILWebURL.document(current.id) {
SwiftUI.ShareLink(item: url) {
Label("Share link", systemImage: "square.and.arrow.up")
}
.accessibilityLabel("Share link")
}
Button("Collaborators") { showCollaborators = true }
Button("Delete", role: .destructive) { showDeleteConfirm = true }
} label: {
Image(systemName: "ellipsis.circle")
Expand All @@ -694,17 +690,16 @@ private struct DocumentDetailView: View {
onUpdate(updated)
}
}
.sheet(isPresented: $showShare) {
ShareLinksSheet(kind: .documents, resourceId: current.id, title: current.title)
.environmentObject(authState)
}
.sheet(isPresented: $showInvites) {
ShareInvitesSheet(kind: .documents, resourceId: current.id, title: current.title)
.environmentObject(authState)
}
.sheet(isPresented: $showCollaborators) {
DocumentCollaboratorsView(documentId: current.id)
.environmentObject(authState)
.sheet(isPresented: $showSharing) {
SharingView(kind: .documents, resourceId: current.id, title: current.title,
isPublic: current.isPublic ?? false) { newValue in
let updated = try await APIClient.shared.updateDocument(
id: current.id, title: current.title, content: current.content,
isPublic: newValue, folderId: nil)
current = updated
onUpdate(updated)
}
.environmentObject(authState)
}
.confirmationDialog("Delete \"\(current.title)\"?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
Button("Delete", role: .destructive) {
Expand Down
Loading
Loading