diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3ab704c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,58 @@
+name: Release
+
+# Cut a versioned GitHub Release with the installable Windows MSI.
+# Trigger by pushing a semver tag, e.g. git tag v1.0.0 && git push origin v1.0.0
+# (or run manually from the Actions tab via workflow_dispatch on a tag).
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+permissions:
+ contents: write # required to create the release + upload assets
+
+jobs:
+ release:
+ name: Build MSI and publish release
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ # Same two-step MSI build as the CI Build workflow: publish the app, then
+ # let WiX harvest the publish output into the installer.
+ - name: Publish app (installer payload)
+ run: dotnet publish InterlinedList/InterlinedList.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=false
+
+ - name: Build MSI
+ run: dotnet build installer/InterlinedList.Installer.wixproj -c Release
+
+ - name: Collect MSI
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force dist | Out-Null
+ $msi = Get-ChildItem -Recurse installer/bin -Filter *.msi | Select-Object -First 1
+ if (-not $msi) { throw "No .msi produced by the WiX build." }
+ Copy-Item $msi.FullName "dist/InterlinedList-Setup.msi"
+ Get-ChildItem dist
+
+ - name: Upload MSI as workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: InterlinedList-Setup-msi
+ path: dist/InterlinedList-Setup.msi
+ if-no-files-found: error
+
+ # On a tag push this creates (or updates) the GitHub Release for that tag
+ # and attaches the MSI as a downloadable asset.
+ - name: Publish GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/InterlinedList-Setup.msi
+ generate_release_notes: true
+ fail_on_unmatched_files: true
diff --git a/CLAUDE.md b/CLAUDE.md
index dd11034..216ea3d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -237,6 +237,13 @@ three real, non-obvious issues surfaced and are fixed in the current state
installed on the runner (see above) — it drifts as GitHub updates runner
images, so a future image update could reintroduce this failure.
+**Releases** — `.github/workflows/release.yml` triggers on a pushed `v*` tag
+(e.g. `git tag v1.0.0 && git push origin v1.0.0`). It runs the same
+publish → WiX MSI build as CI, then attaches `InterlinedList-Setup.msi` to a
+GitHub Release for that tag (auto-generated notes). Keep the tag version in
+sync with `installer/Package.wxs` `Version` and `Package.appxmanifest`
+`Version` (both `1.0.0.0` today) — bump all three together for a new release.
+
## Windows-specific rules
- App icon: `brand-kit/icons/windows/InterlinedList.ico` (set via ApplicationIcon in .csproj)
diff --git a/InterlinedList/LoginWindow.xaml b/InterlinedList/LoginWindow.xaml
index 916a5c5..2031eb9 100644
--- a/InterlinedList/LoginWindow.xaml
+++ b/InterlinedList/LoginWindow.xaml
@@ -110,6 +110,26 @@
BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>
+
+
+
+
+
+
+
+
+
+
+
+
@@ -215,6 +247,29 @@
+
+
+
diff --git a/InterlinedList/LoginWindow.xaml.cs b/InterlinedList/LoginWindow.xaml.cs
index dd5ca50..3fb2fd9 100644
--- a/InterlinedList/LoginWindow.xaml.cs
+++ b/InterlinedList/LoginWindow.xaml.cs
@@ -37,11 +37,23 @@ private async void PasswordInput_KeyDown(object sender, KeyEventArgs e)
private async Task SubmitAsync()
{
- var succeeded = await _viewModel.LoginAsync(PasswordInput.Password);
+ var succeeded = _viewModel.IsRegisterMode
+ ? await _viewModel.RegisterAsync(PasswordInput.Password)
+ : await _viewModel.LoginAsync(PasswordInput.Password);
if (succeeded)
LoginSucceeded?.Invoke(this, EventArgs.Empty);
}
+ private async void BtnForgot_Click(object sender, RoutedEventArgs e)
+ => await _viewModel.ForgotPasswordAsync();
+
+ private void BtnToggleMode_Click(object sender, RoutedEventArgs e)
+ {
+ _viewModel.IsRegisterMode = !_viewModel.IsRegisterMode;
+ RegisterFields.Visibility = _viewModel.IsRegisterMode ? Visibility.Visible : Visibility.Collapsed;
+ BtnLogin.Content = _viewModel.PrimaryButtonText;
+ }
+
private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
@@ -54,7 +66,9 @@ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs
case nameof(LoginViewModel.IsBusy):
BtnLogin.IsEnabled = !_viewModel.IsBusy;
- BtnLogin.Content = _viewModel.IsBusy ? "Logging in…" : "Log In";
+ BtnLogin.Content = _viewModel.IsBusy
+ ? "Working…"
+ : _viewModel.PrimaryButtonText;
break;
}
}
diff --git a/InterlinedList/MainWindow.xaml.cs b/InterlinedList/MainWindow.xaml.cs
index ad0a571..e26f596 100644
--- a/InterlinedList/MainWindow.xaml.cs
+++ b/InterlinedList/MainWindow.xaml.cs
@@ -26,6 +26,8 @@ public MainWindow()
// Feed/search cards raise this to open a user's profile in the People tab.
Navigator.OnOpenProfile = OpenProfile;
+ // Account deletion (Settings) routes back to the login screen through here.
+ Navigator.OnLoggedOut = () => LoggedOut?.Invoke(this, EventArgs.Empty);
StartClock();
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/Models/Message.cs b/InterlinedList/Models/Message.cs
index dcf901b..e335e55 100644
--- a/InterlinedList/Models/Message.cs
+++ b/InterlinedList/Models/Message.cs
@@ -14,6 +14,7 @@ public sealed class Message
public bool DugByMe { get; init; }
public ApiUser? User { get; init; }
public List? ImageUrls { get; init; }
+ public List? VideoUrls { get; init; }
public List? Tags { get; init; }
public string TimeFormatted => CreatedAt.ToUniversalTime().ToString("HH:mm:ss'Z'");
diff --git a/InterlinedList/Models/ShareLink.cs b/InterlinedList/Models/ShareLink.cs
new file mode 100644
index 0000000..ba33c80
--- /dev/null
+++ b/InterlinedList/Models/ShareLink.cs
@@ -0,0 +1,19 @@
+namespace InterlinedList.Models;
+
+///
+/// A tokenized public share link for a list or document. Shape verified live
+/// 2026-08-01: POST returns { token, url, role, expiresAt }; GET adds
+/// createdAt / revokedAt. Revoke with DELETE …/share-links/{token}.
+///
+public sealed class ShareLink
+{
+ public required string Token { get; init; }
+ public string? Url { get; init; }
+ public string? Role { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public DateTimeOffset? CreatedAt { get; init; }
+ public DateTimeOffset? RevokedAt { get; init; }
+
+ public bool IsActive => RevokedAt is null;
+ public string RoleLabel => string.IsNullOrEmpty(Role) ? "viewer" : Role;
+}
diff --git a/InterlinedList/Models/WatchedList.cs b/InterlinedList/Models/WatchedList.cs
new file mode 100644
index 0000000..a63125c
--- /dev/null
+++ b/InterlinedList/Models/WatchedList.cs
@@ -0,0 +1,20 @@
+namespace InterlinedList.Models;
+
+///
+/// A list owned by someone else that the current user has been granted access to
+/// (GET /api/lists/watching). Role is "collaborator" / "viewer" / etc. The
+/// current user can read its rows via the normal list-data endpoint (verified
+/// live 2026-07-31).
+///
+public sealed class WatchedList
+{
+ public required string Id { get; init; }
+ public required string Title { get; init; }
+ public string? Description { get; init; }
+ public bool IsPublic { get; init; }
+ public string? Role { get; init; }
+ public ApiUser? User { get; init; }
+
+ public string OwnerHandle => User is null ? string.Empty : $"@{User.Username}";
+ public string RoleLabel => string.IsNullOrEmpty(Role) ? "viewer" : Role;
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Auth.cs b/InterlinedList/Services/InterlinedApiClient.Auth.cs
new file mode 100644
index 0000000..4838bf3
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Auth.cs
@@ -0,0 +1,20 @@
+using System.Net.Http;
+
+namespace InterlinedList.Services;
+
+///
+/// Pre-login self-service: account registration and password reset. Both are
+/// public (no bearer). Request shapes verified against the OpenAPI spec
+/// 2026-08-01: register { email, username, password, displayName }, forgot
+/// { email }. Responses aren't parsed — on success the caller either logs in
+/// with the new credentials or tells the user to check their inbox.
+///
+public sealed partial class InterlinedApiClient
+{
+ public Task RegisterAsync(string email, string username, string password, string? displayName, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/auth/register",
+ new { email, username, password, displayName }, ct);
+
+ public Task ForgotPasswordAsync(string email, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/auth/forgot-password", new { email }, ct);
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs b/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs
index 623c5a1..5ed9bfb 100644
--- a/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs
+++ b/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs
@@ -1,3 +1,4 @@
+using System.IO;
using System.Net.Http;
using System.Text.Json;
using InterlinedList.Models;
@@ -31,12 +32,33 @@ public async Task GetDmUnreadCountAsync(CancellationToken ct = default)
return json.TryGetProperty("count", out var c) && c.TryGetInt32(out var n) ? n : 0;
}
- public Task SendDmAsync(string recipientId, string body, CancellationToken ct = default)
- => SendVoidAsync(HttpMethod.Post, "api/dm", new { recipientId, body }, ct);
+ public Task SendDmAsync(string recipientId, string body, IReadOnlyList? imageUrls = null, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/dm", new { recipientId, body, imageUrls }, ct);
public Task MarkDmReadAsync(string id, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/dm/{id}/read", new { }, ct);
public Task TrashDmAsync(string id, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/dm/{id}/trash", new { }, ct);
+
+ public Task RestoreDmAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/dm/{id}/restore", new { }, ct);
+
+ /// Upload an image attachment for a DM (multipart field "file" → { url }, verified live).
+ public async Task UploadDmImageAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
+ {
+ var json = await SendMultipartAsync("api/dm/images/upload", content, fileName, contentType, ct: ct);
+ return json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } u
+ ? u
+ : throw new InterlinedApiException(200, "DM image upload returned no url.");
+ }
+
+ /// Lightweight incremental fetch for polling an open thread ({ items }).
+ public async Task> GetDmThreadUpdatesAsync(string username, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/dm/thread/{Uri.EscapeDataString(username)}/updates", ct);
+ return json.TryGetProperty("items", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
}
diff --git a/InterlinedList/Services/InterlinedApiClient.Documents.cs b/InterlinedList/Services/InterlinedApiClient.Documents.cs
index 6b83da8..cbfd6f3 100644
--- a/InterlinedList/Services/InterlinedApiClient.Documents.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Documents.cs
@@ -93,4 +93,46 @@ public Task DeleteDocumentFolderAsync(string id, CancellationToken ct = default)
public Task CreateDocumentInFolderAsync(string folderId, string title, string content, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/documents/folders/{folderId}/documents",
new { title, content, isPublic = false }, ct);
+
+ // ── Share links (create a public read link for a document) ──────────────────
+ // Same shape as list share-links (verified live 2026-08-01).
+
+ public async Task> GetDocumentShareLinksAsync(string documentId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/documents/{documentId}/share-links", ct);
+ return json.TryGetProperty("shareLinks", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task CreateDocumentShareLinkAsync(string documentId, CancellationToken ct = default)
+ => SendJsonAsync(HttpMethod.Post, $"api/documents/{documentId}/share-links", new { }, ct);
+
+ 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 89b9522..bae39cd 100644
--- a/InterlinedList/Services/InterlinedApiClient.Lists.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Lists.cs
@@ -70,4 +70,61 @@ public Task UpdateListRowAsync(string listId, string rowId, Dictionary SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/data/{rowId}", null, ct);
+
+ ///
+ /// Lists owned by others that have been shared with the current user
+ /// (GET /api/lists/watching → { lists, pagination }). Their rows are readable
+ /// via (access is granted server-side).
+ ///
+ public async Task> GetWatchingListsAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/lists/watching", ct);
+ return json.TryGetProperty("lists", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ // ── Share links (create a public read link for a list) ──────────────────────
+ // Shape verified live 2026-08-01: GET → { shareLinks }, POST → the new link,
+ // DELETE …/{token} revokes it.
+
+ public async Task> GetListShareLinksAsync(string listId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/lists/{listId}/share-links", ct);
+ return json.TryGetProperty("shareLinks", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task CreateListShareLinkAsync(string listId, CancellationToken ct = default)
+ => SendJsonAsync(HttpMethod.Post, $"api/lists/{listId}/share-links", new { }, ct);
+
+ 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/Services/InterlinedApiClient.Messages.cs b/InterlinedList/Services/InterlinedApiClient.Messages.cs
index c0a3635..2ff868c 100644
--- a/InterlinedList/Services/InterlinedApiClient.Messages.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Messages.cs
@@ -57,4 +57,22 @@ public async Task UploadMessageImageAsync(Stream content, string fileNam
? u
: throw new InterlinedApiException(200, "Image upload returned no url.");
}
+
+ /// Upload a video; same multipart shape as image upload (field "file" → { url }).
+ public async Task UploadMessageVideoAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
+ {
+ var json = await SendMultipartAsync("api/messages/videos/upload", content, fileName, contentType, ct: ct);
+ return json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } u
+ ? u
+ : throw new InterlinedApiException(200, "Video upload returned no url.");
+ }
+
+ /// GET /api/messages/scheduled → the current user's not-yet-published posts ({ messages }).
+ public async Task> GetScheduledMessagesAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/messages/scheduled", ct);
+ return json.TryGetProperty("messages", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
}
diff --git a/InterlinedList/Services/InterlinedApiClient.cs b/InterlinedList/Services/InterlinedApiClient.cs
index c5ce42f..7d6bd30 100644
--- a/InterlinedList/Services/InterlinedApiClient.cs
+++ b/InterlinedList/Services/InterlinedApiClient.cs
@@ -67,11 +67,12 @@ public async Task PostMessageAsync(
string? parentId = null,
DateTimeOffset? scheduledAt = null,
IReadOnlyList? imageUrls = null,
+ IReadOnlyList? videoUrls = null,
CancellationToken ct = default)
{
// parentId turns this into a reply; scheduledAt defers publication;
- // imageUrls attaches already-uploaded images. All are documented request
- // fields on POST /api/messages (OpenAPI-verified).
+ // image/videoUrls attach already-uploaded media. All are documented
+ // request fields on POST /api/messages (OpenAPI-verified).
using var resp = await SendAsync(HttpMethod.Post, "api/messages", new
{
content,
@@ -82,7 +83,8 @@ public async Task PostMessageAsync(
mastodonProviderIds,
parentId,
scheduledAt = scheduledAt?.UtcDateTime,
- imageUrls
+ imageUrls,
+ videoUrls
}, ct);
await EnsureSuccessAsync(resp, ct);
}
diff --git a/InterlinedList/Services/Navigator.cs b/InterlinedList/Services/Navigator.cs
index 5e05305..7b083e6 100644
--- a/InterlinedList/Services/Navigator.cs
+++ b/InterlinedList/Services/Navigator.cs
@@ -10,9 +10,14 @@ public static class Navigator
{
public static Action? OnOpenProfile { get; set; }
+ /// Set by the shell; lets a center view (e.g. account deletion) return the app to the login screen.
+ public static Action? OnLoggedOut { get; set; }
+
public static void OpenProfile(string username)
{
if (!string.IsNullOrWhiteSpace(username))
OnOpenProfile?.Invoke(username);
}
+
+ public static void RequestLogout() => OnLoggedOut?.Invoke();
}
diff --git a/InterlinedList/ViewModels/DirectMessagesViewModel.cs b/InterlinedList/ViewModels/DirectMessagesViewModel.cs
index 9f07584..2b2ed7f 100644
--- a/InterlinedList/ViewModels/DirectMessagesViewModel.cs
+++ b/InterlinedList/ViewModels/DirectMessagesViewModel.cs
@@ -1,8 +1,11 @@
using System.Collections.ObjectModel;
+using System.IO;
+using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Models;
using InterlinedList.Services;
+using Microsoft.Win32;
namespace InterlinedList.ViewModels;
@@ -10,9 +13,20 @@ public partial class DirectMessagesViewModel : ObservableObject
{
private readonly SessionService _session;
+ // Ids of every message currently shown in the thread — used to dedupe
+ // messages appended by the near-real-time poll.
+ private readonly HashSet _shownMessageIds = new();
+
+ // Polls GetDmThreadUpdatesAsync while a recipient is selected. Fires on the
+ // UI thread, so its Tick handler can mutate the Messages collection directly.
+ private readonly DispatcherTimer _pollTimer;
+
public ObservableCollection Recipients { get; } = new();
public ObservableCollection Messages { get; } = new();
+ // Images uploaded for the next DM (URLs returned by the upload endpoint).
+ public ObservableCollection AttachedImageUrls { get; } = new();
+
[ObservableProperty]
private DmRecipient? selectedRecipient;
@@ -25,6 +39,9 @@ public partial class DirectMessagesViewModel : ObservableObject
[ObservableProperty]
private bool isSending;
+ [ObservableProperty]
+ private bool isUploadingImage;
+
[ObservableProperty]
private string? errorMessage;
@@ -33,6 +50,10 @@ public partial class DirectMessagesViewModel : ObservableObject
public DirectMessagesViewModel(SessionService session)
{
_session = session;
+ AttachedImageUrls.CollectionChanged += (_, _) => SendCommand.NotifyCanExecuteChanged();
+
+ _pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
+ _pollTimer.Tick += async (_, _) => await PollThreadUpdatesAsync();
}
[RelayCommand]
@@ -62,8 +83,12 @@ private async Task LoadAsync()
[RelayCommand]
private async Task SelectRecipientAsync(DmRecipient recipient)
{
+ // Switching conversations: stop polling the old one until the new
+ // thread has loaded, then restart against the new recipient.
+ _pollTimer.Stop();
SelectedRecipient = recipient;
await LoadThreadAsync(recipient);
+ _pollTimer.Start();
}
private async Task LoadThreadAsync(DmRecipient recipient)
@@ -75,8 +100,12 @@ private async Task LoadThreadAsync(DmRecipient recipient)
var thread = await _session.Api.GetDmThreadAsync(recipient.Username);
Messages.Clear();
+ _shownMessageIds.Clear();
foreach (var message in thread.Items)
+ {
Messages.Add(new DmMessageViewModel(message, currentUserId));
+ _shownMessageIds.Add(message.Id);
+ }
ErrorMessage = null;
}
@@ -91,7 +120,8 @@ private async Task LoadThreadAsync(DmRecipient recipient)
}
private bool CanSend() =>
- HasSelection && !IsSending && !string.IsNullOrWhiteSpace(ComposeText);
+ HasSelection && !IsSending &&
+ (!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0);
// SendDmAsync doesn't return a parsed message body (see
// InterlinedApiClient.DirectMessages.cs), so re-fetch the thread after sending.
@@ -104,8 +134,12 @@ private async Task SendAsync()
IsSending = true;
try
{
- await _session.Api.SendDmAsync(recipient.Id, ComposeText.Trim());
+ await _session.Api.SendDmAsync(
+ recipient.Id,
+ ComposeText.Trim(),
+ imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null);
ComposeText = "";
+ AttachedImageUrls.Clear();
await LoadThreadAsync(recipient);
}
catch (InterlinedApiException ex)
@@ -118,10 +152,118 @@ private async Task SendAsync()
}
}
+ [RelayCommand]
+ private async Task AttachImageAsync()
+ {
+ var dlg = new OpenFileDialog
+ {
+ Filter = "Images (*.png;*.jpg;*.jpeg;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.gif;*.webp",
+ Multiselect = false
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ IsUploadingImage = true;
+ try
+ {
+ var contentType = Path.GetExtension(dlg.FileName).ToLowerInvariant() switch
+ {
+ ".png" => "image/png",
+ ".gif" => "image/gif",
+ ".webp" => "image/webp",
+ _ => "image/jpeg",
+ };
+ await using var stream = File.OpenRead(dlg.FileName);
+ var url = await _session.Api.UploadDmImageAsync(stream, Path.GetFileName(dlg.FileName), contentType);
+ AttachedImageUrls.Add(url);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ catch (IOException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsUploadingImage = false;
+ }
+ }
+
+ [RelayCommand]
+ private void RemoveAttachment(string url) => AttachedImageUrls.Remove(url);
+
+ // Trash/restore act on the current user's own messages, then re-fetch the
+ // thread (the write endpoints don't return a parsed body — read-after-write).
+ [RelayCommand]
+ private async Task TrashMessageAsync(DmMessageViewModel message)
+ {
+ try
+ {
+ await _session.Api.TrashDmAsync(message.Id);
+ if (SelectedRecipient is { } recipient)
+ await LoadThreadAsync(recipient);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RestoreMessageAsync(DmMessageViewModel message)
+ {
+ try
+ {
+ await _session.Api.RestoreDmAsync(message.Id);
+ if (SelectedRecipient is { } recipient)
+ await LoadThreadAsync(recipient);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // Lightweight incremental fetch on the poll timer. Appends only messages
+ // whose Id isn't already shown; transient poll failures are swallowed so
+ // they don't spam the error banner.
+ private async Task PollThreadUpdatesAsync()
+ {
+ if (SelectedRecipient is not { } recipient)
+ return;
+
+ try
+ {
+ var currentUserId = _session.CurrentUser?.Id;
+ var updates = await _session.Api.GetDmThreadUpdatesAsync(recipient.Username);
+
+ // The recipient may have changed while the fetch was in flight.
+ if (SelectedRecipient?.Username != recipient.Username)
+ return;
+
+ foreach (var message in updates)
+ {
+ if (_shownMessageIds.Add(message.Id))
+ Messages.Add(new DmMessageViewModel(message, currentUserId));
+ }
+ }
+ catch (InterlinedApiException)
+ {
+ // Transient poll failure — ignore, next tick retries.
+ }
+ }
+
partial void OnSelectedRecipientChanged(DmRecipient? value)
{
OnPropertyChanged(nameof(HasSelection));
SendCommand.NotifyCanExecuteChanged();
+ // No selection → nothing to poll.
+ if (value is null)
+ _pollTimer.Stop();
}
partial void OnComposeTextChanged(string value) => SendCommand.NotifyCanExecuteChanged();
diff --git a/InterlinedList/ViewModels/DmMessageViewModel.cs b/InterlinedList/ViewModels/DmMessageViewModel.cs
index 0356ea6..a3841e5 100644
--- a/InterlinedList/ViewModels/DmMessageViewModel.cs
+++ b/InterlinedList/ViewModels/DmMessageViewModel.cs
@@ -17,7 +17,15 @@ public DmMessageViewModel(DirectMessage message, string? currentUserId)
IsMine = message.SenderId == currentUserId;
}
+ public string Id => _message.Id;
public string Body => _message.Body;
public string TimeFormatted => _message.TimeFormatted;
public bool IsMine { get; }
+
+ public IReadOnlyList ImageUrls => _message.ImageUrls ?? (IReadOnlyList)Array.Empty();
+ public bool HasImages => _message.ImageUrls is { Count: > 0 };
+
+ // A message is "trashed" from this user's perspective when it's their own
+ // and they've soft-deleted it (SenderDeletedAt). The bubble dims/relabels.
+ public bool IsTrashed => IsMine && _message.SenderDeletedAt is not null;
}
diff --git a/InterlinedList/ViewModels/DocumentsViewModel.cs b/InterlinedList/ViewModels/DocumentsViewModel.cs
index 4aa2c97..b36967a 100644
--- a/InterlinedList/ViewModels/DocumentsViewModel.cs
+++ b/InterlinedList/ViewModels/DocumentsViewModel.cs
@@ -14,6 +14,18 @@ public partial class DocumentsViewModel : ObservableObject
public ObservableCollection Folders { get; } = new();
public ObservableCollection Templates { get; } = new();
+ // 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;
@@ -112,11 +124,155 @@ private async Task CreateDocumentAsync()
}
[RelayCommand]
- private void SelectDocument(DocumentSummary doc)
+ 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).
+ private async Task ReloadShareLinksAsync(string documentId)
+ {
+ try
+ {
+ var links = await _session.Api.GetDocumentShareLinksAsync(documentId);
+ ShareLinks.Clear();
+ foreach (var link in links)
+ ShareLinks.Add(link);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // 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))]
+ private async Task CreateShareLinkAsync()
+ {
+ if (SelectedDocument is not { } doc) return;
+
+ try
+ {
+ await _session.Api.CreateDocumentShareLinkAsync(doc.Id);
+ ErrorMessage = null;
+ await ReloadShareLinksAsync(doc.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RevokeShareLinkAsync(ShareLink link)
+ {
+ if (SelectedDocument is not { } doc) return;
+
+ try
+ {
+ await _session.Api.DeleteDocumentShareLinkAsync(doc.Id, link.Token);
+ ErrorMessage = null;
+ await ReloadShareLinksAsync(doc.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void CopyShareLink(ShareLink link)
+ {
+ if (string.IsNullOrEmpty(link.Url)) return;
+
+ try
+ {
+ System.Windows.Clipboard.SetText(link.Url);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── 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;
@@ -149,6 +305,10 @@ private async Task DeleteDocumentAsync(DocumentSummary doc)
SelectedDocument = null;
EditTitle = "";
EditContent = "";
+ ShareLinks.Clear();
+ Collaborators.Clear();
+ CollaboratorSearchResults.Clear();
+ CollaboratorSearchQuery = "";
}
ErrorMessage = null;
await LoadAsync();
@@ -291,7 +451,11 @@ private async Task SaveDocToFolderAsync()
partial void OnNewDocTitleChanged(string value) => CreateDocumentCommand.NotifyCanExecuteChanged();
- partial void OnSelectedDocumentChanged(DocumentSummary? value) => SaveDocumentCommand.NotifyCanExecuteChanged();
+ partial void OnSelectedDocumentChanged(DocumentSummary? value)
+ {
+ SaveDocumentCommand.NotifyCanExecuteChanged();
+ CreateShareLinkCommand.NotifyCanExecuteChanged();
+ }
partial void OnNewFolderNameChanged(string value) => CreateFolderCommand.NotifyCanExecuteChanged();
diff --git a/InterlinedList/ViewModels/FeedViewModel.cs b/InterlinedList/ViewModels/FeedViewModel.cs
index 0e163a9..ad69d36 100644
--- a/InterlinedList/ViewModels/FeedViewModel.cs
+++ b/InterlinedList/ViewModels/FeedViewModel.cs
@@ -54,16 +54,40 @@ public partial class FeedViewModel : ObservableObject
[ObservableProperty]
private bool crossPostToMastodon;
- // Images uploaded for the next post (URLs returned by the upload endpoint).
+ // Media uploaded for the next post (URLs returned by the upload endpoints).
public ObservableCollection AttachedImageUrls { get; } = new();
+ public ObservableCollection AttachedVideoUrls { get; } = new();
[ObservableProperty]
private bool isUploadingImage;
+ [ObservableProperty]
+ private bool isUploadingVideo;
+
+ // Scheduling: when IsScheduling, ScheduleDate + ScheduleTime ("HH:mm") set scheduledAt.
+ [ObservableProperty]
+ private bool isScheduling;
+
+ [ObservableProperty]
+ private DateTime scheduleDate = DateTime.Today;
+
+ [ObservableProperty]
+ private string scheduleTime = "09:00";
+
+ // "Scheduled posts" panel (loaded on demand, toggled from the header).
+ public ObservableCollection ScheduledMessages { get; } = new();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ScheduledToggleLabel))]
+ private bool showScheduled;
+
+ public string ScheduledToggleLabel => ShowScheduled ? "← Back to feed" : "Scheduled";
+
public FeedViewModel(SessionService session)
{
_session = session;
AttachedImageUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged();
+ AttachedVideoUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
@@ -108,6 +132,72 @@ private async Task AttachImageAsync()
[RelayCommand]
private void RemoveAttachment(string url) => AttachedImageUrls.Remove(url);
+ [RelayCommand]
+ private async Task AttachVideoAsync()
+ {
+ var dlg = new OpenFileDialog
+ {
+ Filter = "Videos (*.mp4;*.mov;*.webm;*.m4v)|*.mp4;*.mov;*.webm;*.m4v",
+ Multiselect = false
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ IsUploadingVideo = true;
+ try
+ {
+ var contentType = Path.GetExtension(dlg.FileName).ToLowerInvariant() switch
+ {
+ ".mov" => "video/quicktime",
+ ".webm" => "video/webm",
+ ".m4v" => "video/x-m4v",
+ _ => "video/mp4",
+ };
+ await using var stream = File.OpenRead(dlg.FileName);
+ var url = await _session.Api.UploadMessageVideoAsync(stream, Path.GetFileName(dlg.FileName), contentType);
+ AttachedVideoUrls.Add(url);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ catch (IOException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsUploadingVideo = false;
+ }
+ }
+
+ [RelayCommand]
+ private void RemoveVideoAttachment(string url) => AttachedVideoUrls.Remove(url);
+
+ [RelayCommand]
+ private async Task ToggleScheduledAsync()
+ {
+ ShowScheduled = !ShowScheduled;
+ if (ShowScheduled)
+ await LoadScheduledAsync();
+ }
+
+ private async Task LoadScheduledAsync()
+ {
+ try
+ {
+ var scheduled = await _session.Api.GetScheduledMessagesAsync();
+ ScheduledMessages.Clear();
+ foreach (var m in scheduled)
+ ScheduledMessages.Add(new MessageItemViewModel(m, _session.Api, _session.CurrentUser?.Id));
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
[RelayCommand]
private async Task LoadCrossPostOptionsAsync()
{
@@ -178,7 +268,8 @@ private async Task LoadMoreAsync()
}
}
- private bool CanPost() => !IsPosting && (!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0);
+ private bool CanPost() => !IsPosting &&
+ (!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0 || AttachedVideoUrls.Count > 0);
[RelayCommand(CanExecute = nameof(CanPost))]
private async Task PostAsync()
@@ -192,13 +283,28 @@ await _session.Api.PostMessageAsync(
crossPostToBluesky: CrossPostToBluesky,
crossPostToTwitter: CrossPostToTwitter,
mastodonProviderIds: CrossPostToMastodon ? MastodonProvider : null,
- imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null);
+ scheduledAt: ResolveScheduledAt(),
+ imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null,
+ videoUrls: AttachedVideoUrls.Count > 0 ? AttachedVideoUrls.ToList() : null);
ComposeText = "";
CrossPostToBluesky = false;
CrossPostToTwitter = false;
CrossPostToMastodon = false;
AttachedImageUrls.Clear();
- await RefreshAsync();
+ AttachedVideoUrls.Clear();
+ var wasScheduled = IsScheduling;
+ IsScheduling = false;
+ // A scheduled post won't appear in the live feed — refresh the
+ // scheduled panel instead so the user sees it land.
+ if (wasScheduled)
+ {
+ await LoadScheduledAsync();
+ ShowScheduled = true;
+ }
+ else
+ {
+ await RefreshAsync();
+ }
}
catch (InterlinedApiException ex)
{
@@ -210,6 +316,14 @@ await _session.Api.PostMessageAsync(
}
}
+ private DateTimeOffset? ResolveScheduledAt()
+ {
+ if (!IsScheduling) return null;
+ var time = TimeSpan.TryParse(ScheduleTime, out var t) ? t : new TimeSpan(9, 0, 0);
+ var local = ScheduleDate.Date + time;
+ return new DateTimeOffset(local, TimeZoneInfo.Local.GetUtcOffset(local));
+ }
+
partial void OnHasMoreChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged();
partial void OnIsLoadingChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged();
partial void OnIsLoadingMoreChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged();
diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs
index 7477aa2..fc283fd 100644
--- a/InterlinedList/ViewModels/ListsViewModel.cs
+++ b/InterlinedList/ViewModels/ListsViewModel.cs
@@ -15,6 +15,10 @@ public partial class ListsViewModel : ObservableObject
public ObservableCollection Lists { get; } = new();
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;
@@ -31,6 +35,12 @@ public partial class ListsViewModel : ObservableObject
[ObservableProperty]
private ListSummary? selectedList;
+ [ObservableProperty]
+ private bool isViewingShared;
+
+ [ObservableProperty]
+ private WatchedList? selectedSharedList;
+
[ObservableProperty]
private bool isLoadingRows;
@@ -47,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 };
@@ -109,6 +122,10 @@ private async Task DeleteListAsync(ListSummary list)
{
SelectedList = null;
Rows.Clear();
+ ShareLinks.Clear();
+ Watchers.Clear();
+ WatcherSearchResults.Clear();
+ WatcherSearchQuery = "";
}
await LoadListsAsync();
}
@@ -118,19 +135,189 @@ private async Task DeleteListAsync(ListSummary list)
}
}
+ [RelayCommand]
+ private async Task LoadSharedAsync()
+ {
+ try
+ {
+ var shared = await _session.Api.GetWatchingListsAsync();
+
+ SharedWithMe.Clear();
+ foreach (var list in shared)
+ SharedWithMe.Add(list);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
[RelayCommand]
private async Task SelectListAsync(ListSummary list)
{
+ IsViewingShared = false;
+ SelectedSharedList = null;
SelectedList = list;
- await LoadRowsAsync(list);
+ WatcherSearchQuery = "";
+ WatcherSearchResults.Clear();
+ await LoadRowsAsync(list.Id);
+ await LoadShareLinksAsync(list.Id);
+ await LoadWatchersAsync(list.Id);
}
- private async Task LoadRowsAsync(ListSummary list)
+ [RelayCommand]
+ private async Task SelectSharedListAsync(WatchedList watched)
+ {
+ IsViewingShared = true;
+ SelectedList = null;
+ SelectedSharedList = watched;
+ EditingRow = null;
+ EditRowJson = "";
+ ShareLinks.Clear();
+ Watchers.Clear();
+ WatcherSearchResults.Clear();
+ WatcherSearchQuery = "";
+ await LoadRowsAsync(watched.Id);
+ }
+
+ private async Task LoadShareLinksAsync(string listId)
+ {
+ try
+ {
+ var links = await _session.Api.GetListShareLinksAsync(listId);
+
+ ShareLinks.Clear();
+ foreach (var link in links)
+ ShareLinks.Add(link);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ private bool CanManageShareLinks() => SelectedList is not null && !IsViewingShared;
+
+ [RelayCommand(CanExecute = nameof(CanManageShareLinks))]
+ private async Task CreateShareLinkAsync()
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.CreateListShareLinkAsync(list.Id);
+ await LoadShareLinksAsync(list.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RevokeShareLinkAsync(ShareLink link)
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.DeleteListShareLinkAsync(list.Id, link.Token);
+ await LoadShareLinksAsync(list.Id);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void CopyShareLink(ShareLink link)
+ {
+ if (string.IsNullOrEmpty(link.Url)) return;
+ 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;
try
{
- var page = await _session.Api.GetListDataAsync(list.Id, limit: PageSize, offset: 0);
+ var page = await _session.Api.GetListDataAsync(listId, limit: PageSize, offset: 0);
Rows.Clear();
foreach (var row in page.Rows)
@@ -172,7 +359,7 @@ private async Task AddRowAsync()
await _session.Api.AddListRowAsync(list.Id, parsed);
NewRowJson = "";
RowErrorMessage = null;
- await LoadRowsAsync(list);
+ await LoadRowsAsync(list.Id);
}
catch (InterlinedApiException ex)
{
@@ -218,7 +405,7 @@ private async Task SaveRowEditAsync()
EditingRow = null;
EditRowJson = "";
RowErrorMessage = null;
- await LoadRowsAsync(list);
+ await LoadRowsAsync(list.Id);
}
catch (InterlinedApiException ex)
{
@@ -233,7 +420,7 @@ private async Task DeleteRowAsync(ListDataRow row)
try
{
await _session.Api.DeleteListRowAsync(list.Id, row.Id);
- await LoadRowsAsync(list);
+ await LoadRowsAsync(list.Id);
}
catch (InterlinedApiException ex)
{
@@ -243,7 +430,22 @@ private async Task DeleteRowAsync(ListDataRow row)
partial void OnNewListTitleChanged(string value) => CreateListCommand.NotifyCanExecuteChanged();
- partial void OnSelectedListChanged(ListSummary? value) => AddRowCommand.NotifyCanExecuteChanged();
+ partial void OnSelectedListChanged(ListSummary? value)
+ {
+ AddRowCommand.NotifyCanExecuteChanged();
+ CreateShareLinkCommand.NotifyCanExecuteChanged();
+ SearchWatcherUsersCommand.NotifyCanExecuteChanged();
+ AddWatcherCommand.NotifyCanExecuteChanged();
+ RemoveWatcherCommand.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/LoginViewModel.cs b/InterlinedList/ViewModels/LoginViewModel.cs
index 2c03eb1..09d084b 100644
--- a/InterlinedList/ViewModels/LoginViewModel.cs
+++ b/InterlinedList/ViewModels/LoginViewModel.cs
@@ -10,17 +10,93 @@ public partial class LoginViewModel : ObservableObject
[ObservableProperty]
private string email = "";
+ [ObservableProperty]
+ private string username = "";
+
+ [ObservableProperty]
+ private string displayName = "";
+
[ObservableProperty]
private string? errorMessage;
[ObservableProperty]
private bool isBusy;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(PrimaryButtonText))]
+ [NotifyPropertyChangedFor(nameof(ToggleModeText))]
+ private bool isRegisterMode;
+
+ public string PrimaryButtonText => IsRegisterMode ? "Create account" : "Log In";
+ public string ToggleModeText => IsRegisterMode ? "Have an account? Log in" : "Create an account";
+
public LoginViewModel(SessionService session)
{
_session = session;
}
+ /// Register, then attempt an immediate login. Returns true only if the login succeeds.
+ public async Task RegisterAsync(string password)
+ {
+ if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(password))
+ {
+ ErrorMessage = "Enter an email, username, and password.";
+ return false;
+ }
+
+ IsBusy = true;
+ ErrorMessage = null;
+ try
+ {
+ await _session.Api.RegisterAsync(Email.Trim(), Username.Trim(), password,
+ string.IsNullOrWhiteSpace(DisplayName) ? null : DisplayName.Trim());
+ try
+ {
+ await _session.LoginAsync(Email.Trim(), password);
+ return true;
+ }
+ catch (InterlinedApiException)
+ {
+ ErrorMessage = "Account created. Verify your email, then log in.";
+ IsRegisterMode = false;
+ return false;
+ }
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ return false;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ public async Task ForgotPasswordAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Email))
+ {
+ ErrorMessage = "Enter your email above first.";
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ await _session.Api.ForgotPasswordAsync(Email.Trim());
+ ErrorMessage = "If that email has an account, a reset link is on its way.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
// Password isn't an [ObservableProperty]: PasswordBox.Password can't be safely
// data-bound in WPF, so the code-behind passes the plaintext value in directly.
public async Task LoginAsync(string password)
diff --git a/InterlinedList/ViewModels/MessageItemViewModel.cs b/InterlinedList/ViewModels/MessageItemViewModel.cs
index cd1f081..d87b6f0 100644
--- a/InterlinedList/ViewModels/MessageItemViewModel.cs
+++ b/InterlinedList/ViewModels/MessageItemViewModel.cs
@@ -32,6 +32,9 @@ public partial class MessageItemViewModel : ObservableObject
public IReadOnlyList ImageUrls { get; }
public bool HasImages => ImageUrls.Count > 0;
+ public IReadOnlyList VideoUrls { get; }
+ public bool HasVideos => VideoUrls.Count > 0;
+
public ObservableCollection Replies { get; } = new();
[ObservableProperty]
@@ -82,6 +85,7 @@ public MessageItemViewModel(Message message, InterlinedApiClient api, string? cu
AvatarUrl = message.User?.Avatar;
IsMine = message.UserId == currentUserId;
ImageUrls = message.ImageUrls ?? new List();
+ VideoUrls = message.VideoUrls ?? new List();
digCount = message.DigCount;
dugByMe = message.DugByMe;
@@ -256,6 +260,13 @@ private void OpenAuthor()
Navigator.OpenProfile(AuthorUsername);
}
+ [RelayCommand]
+ private void OpenVideo(string url)
+ {
+ if (!string.IsNullOrEmpty(url))
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true });
+ }
+
partial void OnEditTextChanged(string value) => SaveEditCommand.NotifyCanExecuteChanged();
partial void OnReplyTextChanged(string value) => PostReplyCommand.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..db8b710 100644
--- a/InterlinedList/ViewModels/SettingsViewModel.cs
+++ b/InterlinedList/ViewModels/SettingsViewModel.cs
@@ -44,6 +44,10 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private string newEmail = "";
+ // Account deletion requires typing your exact username as a guard.
+ [ObservableProperty]
+ private string deleteConfirmUsername = "";
+
public SettingsViewModel(SessionService session)
{
_session = session;
@@ -65,6 +69,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()
{
@@ -224,6 +238,35 @@ private async Task UnmuteAsync(ModeratedUser u)
[RelayCommand]
private Task ExportFollowsAsync() => ExportCsvAsync(_session.Api.ExportFollowsCsvAsync, "follows.csv");
+ // Destructive. Enabled only when the typed username matches exactly. On
+ // success the token is cleared and the shell returns to the login screen.
+ private bool CanDeleteAccount() =>
+ _session.CurrentUser is { } u &&
+ string.Equals(DeleteConfirmUsername.Trim(), u.Username, StringComparison.Ordinal);
+
+ [RelayCommand(CanExecute = nameof(CanDeleteAccount))]
+ private async Task DeleteAccountAsync()
+ {
+ if (_session.CurrentUser is not { } user) return;
+ IsBusy = true;
+ try
+ {
+ await _session.Api.DeleteAccountAsync(user.Username, user.Email);
+ _session.Logout();
+ Navigator.RequestLogout();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ partial void OnDeleteConfirmUsernameChanged(string value) => DeleteAccountCommand.NotifyCanExecuteChanged();
+
private async Task ExportCsvAsync(Func> fetch, string defaultFileName)
{
try
diff --git a/InterlinedList/Views/DirectMessagesView.xaml b/InterlinedList/Views/DirectMessagesView.xaml
index 8d5a87f..ead0224 100644
--- a/InterlinedList/Views/DirectMessagesView.xaml
+++ b/InterlinedList/Views/DirectMessagesView.xaml
@@ -41,6 +41,33 @@
+
+
+
@@ -236,6 +263,18 @@
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"
LineHeight="20"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -290,31 +377,72 @@
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="0,1,0,0"
Padding="16,12">
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml
index 89c55a0..593ec5c 100644
--- a/InterlinedList/Views/DocumentsView.xaml
+++ b/InterlinedList/Views/DocumentsView.xaml
@@ -101,6 +101,16 @@
+
+
+
@@ -369,6 +379,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/FeedView.xaml b/InterlinedList/Views/FeedView.xaml
index 39ecf07..3ca047d 100644
--- a/InterlinedList/Views/FeedView.xaml
+++ b/InterlinedList/Views/FeedView.xaml
@@ -146,11 +146,17 @@
FontSize="17" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
-
+
+
+
+
@@ -220,15 +226,58 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -260,6 +309,17 @@
HorizontalScrollBarVisibility="Disabled"
Padding="16,12">
+
+
+
+
+
@@ -352,6 +412,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index a210194..eaf3e0f 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -167,41 +167,104 @@
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="16,0,16,16">
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -220,13 +283,55 @@
BorderThickness="0,0,0,1"
Padding="20,12">
+
+ Foreground="{DynamicResource TextBrush}">
+
+
+
+
+
+
+
+
+
+
+
+
+ Foreground="{DynamicResource TextMutedBrush}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -252,6 +585,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/the-gaps.md b/the-gaps.md
index 3934932..081c89f 100644
--- a/the-gaps.md
+++ b/the-gaps.md
@@ -115,6 +115,115 @@ billing, register/forgot-password) are tracked below as post-v1.
---
+## Progress — Session 4 (2026-08-01) — media, scheduling, DM depth, sharing
+
+Verified last shapes live (video upload multipart field **`file`** → `{url}`;
+DM image upload same; I **can read a shared list's rows** with the bearer token).
+App builds clean in Debug + Release.
+
+**Shipped this session:**
+- ✅ **Scheduled posts** — compose date/time picker + a header toggle showing
+ your scheduled posts (`GET /api/messages/scheduled`, post with `scheduledAt`).
+- ✅ **Video upload** on posts — attach from disk, chips in compose, "🎬 Play
+ video" link in cards (opens in browser).
+- ✅ **Direct Messages depth** *(sub-agent)* — image attachments (upload +
+ display), trash/restore own messages, and **5-second live polling** of the
+ open thread (`.../updates`, deduped by id).
+- ✅ **Lists "Shared with me"** *(sub-agent)* — lists others shared with you
+ (`GET /api/lists/watching`), read-only row viewing.
+- Services: video upload, scheduled fetch, DM image/restore/updates, `videoUrls`
+ on compose, `GetWatchingListsAsync`.
+
+**Remaining post-v1:** list watcher *management* + share-link creation (empty
+data on the test account — needs a second account to verify), document sharing/
+collaborators, Materialize, GitHub (needs GitHub linked), billing handoff,
+register/forgot-password, account-deletion UI, DM inbox-folder view.
+
+---
+
+## Progress — Session 5 (2026-08-01) — sharing + auth self-service
+
+Live-verified share-link shape (POST → `{token,url,role,expiresAt}`, GET →
+`{shareLinks:[…]}`, DELETE by token; created+deleted a real test link). App
+builds clean in Debug + Release.
+
+**Shipped this session:**
+- ✅ **Public share links** for **Lists** and **Documents** — create / list /
+ revoke / copy-URL, gated to items you own.
+- ✅ **Auth self-service** — **Register** (email/username/password/display name)
+ and **Forgot password** in the login window (mode toggle + reset request).
+- Services: `RegisterAsync`, `ForgotPasswordAsync`, list + document share-link
+ create/list/delete, `ShareLink` model.
+
+**Remaining post-v1:** watcher/collaborator *member* management (undocumented
+request bodies + empty data — needs a second account), Materialize, GitHub
+(needs GitHub linked), billing handoff, account-deletion UI, DM inbox-folder,
+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.
+
+---
+
+## Progress — Session 7 (2026-08-01) — account deletion + parity assessment
+
+- ✅ **Account deletion** — a guarded "Danger zone" in Settings (type your exact
+ username to enable), calling `POST /api/user/delete {username,email}`, then
+ clearing the token and returning the shell to the login screen (via a new
+ `Navigator.OnLoggedOut` hook). Builds clean Debug + Release.
+
+### ✅ Effective feature parity reached
+Every core, verifiable, user-facing product surface is now built (feed +
+media + replies + scheduling, DMs with media/polling, People + full follow
+graph + moderation, Settings incl. sessions/exports/account, Lists + rows +
+folders + sharing + watchers, Documents + folders + sharing + collaborators,
+Organizations + members, Search, Connected Accounts, auth self-service).
+
+**The only remaining gaps are genuinely blocked, not merely unbuilt:**
+- **Materialize** ("Create from…") — the API request is a single opaque
+ `source` string with no documented structure; can't be built reliably without
+ more API detail. *API-blocked.*
+- **Per-list schema/columns DSL** (`PUT /api/lists/{id}/schema`) — only partially
+ reverse-engineered; schema-less rows are the confirmed-working path (see
+ CLAUDE.md). *API-blocked.*
+- **GitHub issue sync** — the test account has no GitHub linked (every
+ `/api/github/*` call returns "GitHub account not linked") and the wire shapes
+ aren't documented, so it can't be verified. *Verification-blocked* — build it
+ once an account with GitHub linked is available.
+- **DM inbox-folder view** — `GET /api/dm` item shape can't be learned without
+ sending real DMs to a real person; DMs already work via the recipients list.
+ *Low value / verification-blocked.*
+- **Billing UI** — Stripe endpoints are cookie-session-only; handled by the
+ "Manage account on the web" handoff. *Auth-model-blocked (by design.)*
+
+To close the verification-blocked items, provision **(a)** a second test account
+(two-sided DM/follow/moderation/sharing checks) and **(b)** GitHub linked on a
+test account. Everything else is either shipped or API-limited.
+
+---
+
## 1. Parity snapshot by domain
| Domain (product's name) | Web/API has | App has today | Status |