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
20 changes: 20 additions & 0 deletions InterlinedList/Models/Collaborator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace InterlinedList.Models;

/// <summary>
/// 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 <see cref="UserId"/>
/// (the DELETE routes are keyed by user id, not the edge id).
/// </summary>
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;
}
25 changes: 25 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Documents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,29 @@ public Task<ShareLink> 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<List<Collaborator>> 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<List<Collaborator>>(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<List<UserSearchResult>> 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<List<UserSearchResult>>(JsonOptions) ?? new()
: new();
}
}
26 changes: 26 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Lists.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,30 @@ public Task<ShareLink> 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<List<Collaborator>> 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<List<Collaborator>>(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<List<UserSearchResult>> 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<List<UserSearchResult>>(JsonOptions) ?? new()
: new();
}
}
88 changes: 88 additions & 0 deletions InterlinedList/ViewModels/DocumentsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ public partial class DocumentsViewModel : ObservableObject
// Public share links for the currently-open document (empty when none open).
public ObservableCollection<ShareLink> ShareLinks { get; } = new();

// Collaborators granted shared access to the currently-open document (empty when none open).
public ObservableCollection<Collaborator> Collaborators { get; } = new();

// Results of the collaborator user search (empty until a search runs).
public ObservableCollection<UserSearchResult> CollaboratorSearchResults { get; } = new();

[ObservableProperty]
private string collaboratorSearchQuery = "";

[ObservableProperty]
private bool isLoading;

Expand Down Expand Up @@ -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).
Expand All @@ -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))]
Expand Down Expand Up @@ -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))]
Expand Down Expand Up @@ -221,6 +306,9 @@ private async Task DeleteDocumentAsync(DocumentSummary doc)
EditTitle = "";
EditContent = "";
ShareLinks.Clear();
Collaborators.Clear();
CollaboratorSearchResults.Clear();
CollaboratorSearchQuery = "";
}
ErrorMessage = null;
await LoadAsync();
Expand Down
100 changes: 99 additions & 1 deletion InterlinedList/ViewModels/ListsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public partial class ListsViewModel : ObservableObject
public ObservableCollection<ListDataRow> Rows { get; } = new();
public ObservableCollection<WatchedList> SharedWithMe { get; } = new();
public ObservableCollection<ShareLink> ShareLinks { get; } = new();
public ObservableCollection<Collaborator> Watchers { get; } = new();
public ObservableCollection<UserSearchResult> WatcherSearchResults { get; } = new();

[ObservableProperty]
private bool isLoading;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -118,6 +123,9 @@ private async Task DeleteListAsync(ListSummary list)
SelectedList = null;
Rows.Clear();
ShareLinks.Clear();
Watchers.Clear();
WatcherSearchResults.Clear();
WatcherSearchQuery = "";
}
await LoadListsAsync();
}
Expand Down Expand Up @@ -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]
Expand All @@ -165,6 +176,9 @@ private async Task SelectSharedListAsync(WatchedList watched)
EditingRow = null;
EditRowJson = "";
ShareLinks.Clear();
Watchers.Clear();
WatcherSearchResults.Clear();
WatcherSearchQuery = "";
await LoadRowsAsync(watched.Id);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Loading
Loading