From cef7cf71881fae1674245b2f0d215da2145c2974 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 17:44:33 -0700 Subject: [PATCH] Add collaboration (watchers/collaborators) and profile depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 6 of the parity build-out (branch feature/parity-v4). Builds clean in Debug and Release. Watcher/collaborator shapes verified live 2026-08-01 (POST {userId,role} -> 201, GET -> {...:[{id,userId,role,createdAt,user}]}, DELETE .../{userId}, /users?q= search). Collaboration: - List watchers — invite a user to a list you own via search, list, remove - Document collaborators — same for documents (both added as sibling sections to the share-link panels from batch 5) Profile / account: - Mutual connections on a profile (clickable chips that open that user) - "Manage account on the web" handoff in Settings (billing is cookie-only server-side, so hand off to the site like OAuth linking) Services: list watcher + doc collaborator CRUD + user-search, Collaborator model, GetMutualAsync surfaced. Materialize deferred (opaque {source} request). See the-gaps.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- InterlinedList/Models/Collaborator.cs | 20 +++ .../Services/InterlinedApiClient.Documents.cs | 25 ++++ .../Services/InterlinedApiClient.Lists.cs | 26 ++++ .../ViewModels/DocumentsViewModel.cs | 88 +++++++++++ InterlinedList/ViewModels/ListsViewModel.cs | 100 ++++++++++++- InterlinedList/ViewModels/ProfileViewModel.cs | 15 ++ .../ViewModels/SettingsViewModel.cs | 10 ++ InterlinedList/Views/DocumentsView.xaml | 112 ++++++++++++++ InterlinedList/Views/ListsView.xaml | 138 ++++++++++++++++++ InterlinedList/Views/PeopleView.xaml | 35 +++++ InterlinedList/Views/SettingsView.xaml | 9 +- c.json | 1 + the-gaps.md | 25 ++++ 13 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 InterlinedList/Models/Collaborator.cs create mode 100644 c.json 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/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 @@ - +