diff --git a/InterlinedList/Models/Collaborator.cs b/InterlinedList/Models/Collaborator.cs
new file mode 100644
index 0000000..8d32f2b
--- /dev/null
+++ b/InterlinedList/Models/Collaborator.cs
@@ -0,0 +1,20 @@
+namespace InterlinedList.Models;
+
+///
+/// A person granted shared access to a list (a "watcher") or a document (a
+/// "collaborator"). Same wire shape for both (verified live 2026-08-01):
+/// { id, userId, role, createdAt, user }. Remove using
+/// (the DELETE routes are keyed by user id, not the edge id).
+///
+public sealed class Collaborator
+{
+ public required string Id { get; init; }
+ public required string UserId { get; init; }
+ public string? Role { get; init; }
+ public DateTimeOffset? CreatedAt { get; init; }
+ public ApiUser? User { get; init; }
+
+ public string DisplayNameOrUsername => User?.DisplayName ?? User?.Username ?? "unknown";
+ public string Handle => User is null ? string.Empty : $"@{User.Username}";
+ public string RoleLabel => string.IsNullOrEmpty(Role) ? "watcher" : Role;
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Documents.cs b/InterlinedList/Services/InterlinedApiClient.Documents.cs
index d12454e..cbfd6f3 100644
--- a/InterlinedList/Services/InterlinedApiClient.Documents.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Documents.cs
@@ -110,4 +110,29 @@ public Task CreateDocumentShareLinkAsync(string documentId, Cancellat
public Task DeleteDocumentShareLinkAsync(string documentId, string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/documents/{documentId}/share-links/{token}", null, ct);
+
+ // ── Collaborators (per-user shared access to a document) ────────────────────
+ // Same shape as list watchers (verified live 2026-08-01).
+
+ public async Task> GetDocumentCollaboratorsAsync(string documentId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/documents/{documentId}/collaborators", ct);
+ return json.TryGetProperty("collaborators", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task AddDocumentCollaboratorAsync(string documentId, string userId, string role = "watcher", CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/documents/{documentId}/collaborators", new { userId, role }, ct);
+
+ public Task RemoveDocumentCollaboratorAsync(string documentId, string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/documents/{documentId}/collaborators/{userId}", null, ct);
+
+ public async Task> SearchCollaboratorUsersAsync(string documentId, string query, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/documents/{documentId}/collaborators/users?q={Uri.EscapeDataString(query)}", ct);
+ return json.TryGetProperty("users", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
}
diff --git a/InterlinedList/Services/InterlinedApiClient.Lists.cs b/InterlinedList/Services/InterlinedApiClient.Lists.cs
index 55edfb4..bae39cd 100644
--- a/InterlinedList/Services/InterlinedApiClient.Lists.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Lists.cs
@@ -101,4 +101,30 @@ public Task CreateListShareLinkAsync(string listId, CancellationToken
public Task DeleteListShareLinkAsync(string listId, string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/share-links/{token}", null, ct);
+
+ // ── Watchers (per-user shared access to a list) ─────────────────────────────
+ // Shapes verified live 2026-08-01: GET → { watchers }, POST { userId, role }
+ // → 201, DELETE …/{userId}. Role defaults to "watcher".
+
+ public async Task> GetListWatchersAsync(string listId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/lists/{listId}/watchers", ct);
+ return json.TryGetProperty("watchers", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task AddListWatcherAsync(string listId, string userId, string role = "watcher", CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/lists/{listId}/watchers", new { userId, role }, ct);
+
+ public Task RemoveListWatcherAsync(string listId, string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/watchers/{userId}", null, ct);
+
+ public async Task> SearchListWatcherUsersAsync(string listId, string query, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/lists/{listId}/watchers/users?q={Uri.EscapeDataString(query)}", ct);
+ return json.TryGetProperty("users", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
}
diff --git a/InterlinedList/ViewModels/DocumentsViewModel.cs b/InterlinedList/ViewModels/DocumentsViewModel.cs
index 27ea094..b36967a 100644
--- a/InterlinedList/ViewModels/DocumentsViewModel.cs
+++ b/InterlinedList/ViewModels/DocumentsViewModel.cs
@@ -17,6 +17,15 @@ public partial class DocumentsViewModel : ObservableObject
// Public share links for the currently-open document (empty when none open).
public ObservableCollection ShareLinks { get; } = new();
+ // Collaborators granted shared access to the currently-open document (empty when none open).
+ public ObservableCollection Collaborators { get; } = new();
+
+ // Results of the collaborator user search (empty until a search runs).
+ public ObservableCollection CollaboratorSearchResults { get; } = new();
+
+ [ObservableProperty]
+ private string collaboratorSearchQuery = "";
+
[ObservableProperty]
private bool isLoading;
@@ -120,7 +129,10 @@ private async Task SelectDocumentAsync(DocumentSummary doc)
SelectedDocument = doc;
EditTitle = doc.Title;
EditContent = doc.Content;
+ CollaboratorSearchQuery = "";
+ CollaboratorSearchResults.Clear();
await ReloadShareLinksAsync(doc.Id);
+ await ReloadCollaboratorsAsync(doc.Id);
}
// Refresh ShareLinks from the server for the given document (read-after-write).
@@ -139,6 +151,22 @@ private async Task ReloadShareLinksAsync(string documentId)
}
}
+ // Refresh Collaborators from the server for the given document (read-after-write).
+ private async Task ReloadCollaboratorsAsync(string documentId)
+ {
+ try
+ {
+ var collaborators = await _session.Api.GetDocumentCollaboratorsAsync(documentId);
+ Collaborators.Clear();
+ foreach (var collaborator in collaborators)
+ Collaborators.Add(collaborator);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
private bool CanShareSelectedDocument() => SelectedDocument is not null;
[RelayCommand(CanExecute = nameof(CanShareSelectedDocument))]
@@ -190,6 +218,63 @@ private void CopyShareLink(ShareLink link)
}
}
+ // ── Collaborator management ───────────────────────────────────
+
+ [RelayCommand]
+ private async Task SearchCollaboratorUsersAsync()
+ {
+ if (SelectedDocument is not { } doc) return;
+
+ try
+ {
+ var results = await _session.Api.SearchCollaboratorUsersAsync(doc.Id, CollaboratorSearchQuery);
+ CollaboratorSearchResults.Clear();
+ foreach (var result in results)
+ CollaboratorSearchResults.Add(result);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddCollaboratorAsync(UserSearchResult user)
+ {
+ if (SelectedDocument is not { } doc) return;
+
+ try
+ {
+ await _session.Api.AddDocumentCollaboratorAsync(doc.Id, user.Id);
+ CollaboratorSearchQuery = "";
+ CollaboratorSearchResults.Clear();
+ ErrorMessage = null;
+ await ReloadCollaboratorsAsync(doc.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RemoveCollaboratorAsync(Collaborator collaborator)
+ {
+ if (SelectedDocument is not { } doc) return;
+
+ try
+ {
+ await _session.Api.RemoveDocumentCollaboratorAsync(doc.Id, collaborator.UserId);
+ ErrorMessage = null;
+ await ReloadCollaboratorsAsync(doc.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
private bool CanSaveDocument() => SelectedDocument is not null;
[RelayCommand(CanExecute = nameof(CanSaveDocument))]
@@ -221,6 +306,9 @@ private async Task DeleteDocumentAsync(DocumentSummary doc)
EditTitle = "";
EditContent = "";
ShareLinks.Clear();
+ Collaborators.Clear();
+ CollaboratorSearchResults.Clear();
+ CollaboratorSearchQuery = "";
}
ErrorMessage = null;
await LoadAsync();
diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs
index c31dd76..fc283fd 100644
--- a/InterlinedList/ViewModels/ListsViewModel.cs
+++ b/InterlinedList/ViewModels/ListsViewModel.cs
@@ -17,6 +17,8 @@ public partial class ListsViewModel : ObservableObject
public ObservableCollection Rows { get; } = new();
public ObservableCollection SharedWithMe { get; } = new();
public ObservableCollection ShareLinks { get; } = new();
+ public ObservableCollection Watchers { get; } = new();
+ public ObservableCollection WatcherSearchResults { get; } = new();
[ObservableProperty]
private bool isLoading;
@@ -55,6 +57,9 @@ public partial class ListsViewModel : ObservableObject
[ObservableProperty]
private string editRowJson = "";
+ [ObservableProperty]
+ private string watcherSearchQuery = "";
+
public bool IsEditingRow => EditingRow is not null;
private static readonly JsonSerializerOptions RowEditJsonOptions = new() { WriteIndented = true };
@@ -118,6 +123,9 @@ private async Task DeleteListAsync(ListSummary list)
SelectedList = null;
Rows.Clear();
ShareLinks.Clear();
+ Watchers.Clear();
+ WatcherSearchResults.Clear();
+ WatcherSearchQuery = "";
}
await LoadListsAsync();
}
@@ -152,8 +160,11 @@ private async Task SelectListAsync(ListSummary list)
IsViewingShared = false;
SelectedSharedList = null;
SelectedList = list;
+ WatcherSearchQuery = "";
+ WatcherSearchResults.Clear();
await LoadRowsAsync(list.Id);
await LoadShareLinksAsync(list.Id);
+ await LoadWatchersAsync(list.Id);
}
[RelayCommand]
@@ -165,6 +176,9 @@ private async Task SelectSharedListAsync(WatchedList watched)
EditingRow = null;
EditRowJson = "";
ShareLinks.Clear();
+ Watchers.Clear();
+ WatcherSearchResults.Clear();
+ WatcherSearchQuery = "";
await LoadRowsAsync(watched.Id);
}
@@ -223,6 +237,81 @@ private void CopyShareLink(ShareLink link)
System.Windows.Clipboard.SetText(link.Url);
}
+ // ── Watchers (per-user shared access to an own list) ────────────────────────
+
+ private async Task LoadWatchersAsync(string listId)
+ {
+ try
+ {
+ var watchers = await _session.Api.GetListWatchersAsync(listId);
+
+ Watchers.Clear();
+ foreach (var watcher in watchers)
+ Watchers.Add(watcher);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ private bool CanManageWatchers() => SelectedList is not null && !IsViewingShared;
+
+ [RelayCommand(CanExecute = nameof(CanManageWatchers))]
+ private async Task SearchWatcherUsersAsync()
+ {
+ if (SelectedList is not { } list) return;
+ if (string.IsNullOrWhiteSpace(WatcherSearchQuery)) return;
+ try
+ {
+ var users = await _session.Api.SearchListWatcherUsersAsync(list.Id, WatcherSearchQuery.Trim());
+
+ WatcherSearchResults.Clear();
+ foreach (var user in users)
+ WatcherSearchResults.Add(user);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanManageWatchers))]
+ private async Task AddWatcherAsync(UserSearchResult user)
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.AddListWatcherAsync(list.Id, user.Id);
+ WatcherSearchQuery = "";
+ WatcherSearchResults.Clear();
+ await LoadWatchersAsync(list.Id);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanManageWatchers))]
+ private async Task RemoveWatcherAsync(Collaborator watcher)
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.RemoveListWatcherAsync(list.Id, watcher.UserId);
+ await LoadWatchersAsync(list.Id);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
private async Task LoadRowsAsync(string listId)
{
IsLoadingRows = true;
@@ -345,9 +434,18 @@ partial void OnSelectedListChanged(ListSummary? value)
{
AddRowCommand.NotifyCanExecuteChanged();
CreateShareLinkCommand.NotifyCanExecuteChanged();
+ SearchWatcherUsersCommand.NotifyCanExecuteChanged();
+ AddWatcherCommand.NotifyCanExecuteChanged();
+ RemoveWatcherCommand.NotifyCanExecuteChanged();
}
- partial void OnIsViewingSharedChanged(bool value) => CreateShareLinkCommand.NotifyCanExecuteChanged();
+ partial void OnIsViewingSharedChanged(bool value)
+ {
+ CreateShareLinkCommand.NotifyCanExecuteChanged();
+ SearchWatcherUsersCommand.NotifyCanExecuteChanged();
+ AddWatcherCommand.NotifyCanExecuteChanged();
+ RemoveWatcherCommand.NotifyCanExecuteChanged();
+ }
partial void OnNewRowJsonChanged(string value) => AddRowCommand.NotifyCanExecuteChanged();
}
diff --git a/InterlinedList/ViewModels/ProfileViewModel.cs b/InterlinedList/ViewModels/ProfileViewModel.cs
index 0a5308a..00a1c2c 100644
--- a/InterlinedList/ViewModels/ProfileViewModel.cs
+++ b/InterlinedList/ViewModels/ProfileViewModel.cs
@@ -17,6 +17,7 @@ public partial class ProfileViewModel : ObservableObject
public ObservableCollection FollowRequests { get; } = new();
public ObservableCollection Messages { get; } = new();
+ public ObservableCollection Mutuals { get; } = new();
[ObservableProperty]
private string lookupUsername = "";
@@ -47,6 +48,8 @@ public partial class ProfileViewModel : ObservableObject
public bool HasProfile => Profile is not null;
+ public bool HasMutuals => Mutuals.Count > 0;
+
public string FollowButtonText =>
Relationship?.IsFollowing == true ? "Following"
: Relationship?.IsPending == true ? "Requested"
@@ -60,6 +63,7 @@ public ProfileViewModel(SessionService session)
{
_session = session;
FollowRequests.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRequests));
+ Mutuals.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasMutuals));
}
[RelayCommand]
@@ -108,6 +112,10 @@ private async Task LoadProfileAsync()
foreach (var message in page.Messages)
Messages.Add(new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id));
+ Mutuals.Clear();
+ foreach (var mutual in await _session.Api.GetMutualAsync(profile.Id))
+ Mutuals.Add(mutual);
+
ErrorMessage = null;
}
catch (InterlinedApiException ex)
@@ -195,6 +203,13 @@ private async Task ReportAsync()
}
}
+ [RelayCommand]
+ private async Task OpenUserAsync(FollowUser user)
+ {
+ LookupUsername = user.Username;
+ await LoadProfileAsync();
+ }
+
[RelayCommand]
private async Task ApproveAsync(FollowUser user)
{
diff --git a/InterlinedList/ViewModels/SettingsViewModel.cs b/InterlinedList/ViewModels/SettingsViewModel.cs
index 2656c5e..dafc684 100644
--- a/InterlinedList/ViewModels/SettingsViewModel.cs
+++ b/InterlinedList/ViewModels/SettingsViewModel.cs
@@ -65,6 +65,16 @@ private async Task SetAvatarAsync()
}
}
+ // Billing/subscription is cookie-session-only server-side, so the native app
+ // hands off to the website (same pattern as OAuth linking).
+ [RelayCommand]
+ private void OpenWebAccount()
+ => System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = ApiConfig.BaseUrl,
+ UseShellExecute = true
+ });
+
[RelayCommand]
private async Task ChangeEmailAsync()
{
diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml
index 5880ce1..593ec5c 100644
--- a/InterlinedList/Views/DocumentsView.xaml
+++ b/InterlinedList/Views/DocumentsView.xaml
@@ -380,6 +380,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 18665f2..eaf3e0f 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -435,6 +435,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/PeopleView.xaml b/InterlinedList/Views/PeopleView.xaml
index 13b9826..d3500ae 100644
--- a/InterlinedList/Views/PeopleView.xaml
+++ b/InterlinedList/Views/PeopleView.xaml
@@ -336,6 +336,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/SettingsView.xaml b/InterlinedList/Views/SettingsView.xaml
index 27f0dbd..c869736 100644
--- a/InterlinedList/Views/SettingsView.xaml
+++ b/InterlinedList/Views/SettingsView.xaml
@@ -271,13 +271,20 @@
-
+
+
+
+
diff --git a/c.json b/c.json
new file mode 100644
index 0000000..0b5e4c4
--- /dev/null
+++ b/c.json
@@ -0,0 +1 @@
+{"collaborating":true}
\ No newline at end of file
diff --git a/the-gaps.md b/the-gaps.md
index baad0d8..ba8900b 100644
--- a/the-gaps.md
+++ b/the-gaps.md
@@ -162,6 +162,31 @@ mutual-follows display, per-list schema/columns.
---
+## Progress — Session 6 (2026-08-01) — collaboration + profile depth
+
+Live-verified watcher/collaborator shapes (`POST {userId,role}`→201, `GET`→
+`{watchers|collaborators:[{id,userId,role,createdAt,user}]}`, `DELETE …/{userId}`,
+plus `/users?q=` search — added + removed real test edges). Builds clean
+Debug + Release.
+
+**Shipped this session:**
+- ✅ **List watchers** — invite users to a list you own (via search), list, remove.
+- ✅ **Document collaborators** — same for documents.
+- ✅ **Mutual connections** on a profile (People) — chips that open that user.
+- ✅ **Manage account on the web** handoff (Settings) — billing/subscription is
+ cookie-only server-side, so we hand off to the site (like OAuth linking).
+- Services: list watcher + doc collaborator CRUD + user-search, `Collaborator`
+ model, `GetMutualAsync` surfaced.
+
+**Materialize** stays deferred — its request is a single opaque `source` string;
+not enough to build reliably without more API detail.
+
+**Remaining post-v1:** Materialize, GitHub (needs GitHub linked on the account),
+account-deletion UI (destructive — intentionally deferred), DM inbox-folder view,
+per-list schema/columns, a full standalone notifications view.
+
+---
+
## 1. Parity snapshot by domain
| Domain (product's name) | Web/API has | App has today | Status |