diff --git a/CLAUDE.md b/CLAUDE.md
index 239ddb3..dd11034 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,31 +74,52 @@ The app talks to the real InterlinedList backend at `https://interlinedlist.com`
(154-endpoint REST API, OpenAPI spec at `/api/openapi.json`) — there is no mock
data layer. Auth is a long-lived bearer token from `POST /api/auth/sync-token`
(the same mechanism the `il-sync` CLI and other native clients use — no cookie
-jar), persisted DPAPI-encrypted via `CredentialStore`. **There is no
-server-side revoke endpoint for this token** — treat `%LocalAppData%\InterlinedList\session.dat`
-as a standing credential.
-
-Covered now: login/session restore, paginated feed, compose, Dig/Undig,
-notifications tray, profile/follow-counts rail, **Lists** (browse/create/
-delete, freeform JSON data rows — no schema/column editor, see below),
-**Documents** (personal markdown notes: root docs, folders, templates,
-create/edit/delete), **Organizations** (browse orgs you belong to + the
-public directory, create — **no member management**, see below), unified
-**Search** (messages/people/lists/documents from one box), and **Connected
-Accounts** (Bluesky/Mastodon/LinkedIn/Twitter linking + compose-time
-cross-post toggles). Still not built: Stripe billing, replies/threads,
-register/forgot-password, GitHub issue sync, per-list schema/column
-definitions, LinkedIn per-page posting targets.
-
-**Two real, load-bearing constraints discovered by live-probing the API — don't
+jar), persisted DPAPI-encrypted via `CredentialStore`. The token is
+long-lived, so treat `%LocalAppData%\InterlinedList\session.dat` as a standing
+credential — but note the server **does** expose session management:
+`GET /api/user/sessions` lists a user's active sync tokens and
+`DELETE /api/user/sessions/{id}` revokes one (both accept the bearer token —
+verified live 2026-07-31). An earlier revision of this file claimed no revoke
+endpoint existed; that is no longer true.
+
+Covered now (greatly expanded in the 2026-07-31 parity build-out):
+login/session restore, paginated **feed** with compose (text + **image
+attachments**, cross-post toggles), Dig/Undig, **replies/threads**, **edit/
+delete** own posts, **report** posts, and **click-through to author profiles**;
+notifications tray with **mark-one-read / delete-one / mark-all**; **Direct
+Messages** (recipient list + thread + send); **People** (profile lookup,
+follow/unfollow, follow-request approve/reject, a user's messages,
+**block/mute/report**); **Lists** (browse/create/delete, freeform JSON data
+rows with **row edit + delete** — no schema/column editor, see below);
+**Documents** (root docs, templates, create/edit/delete + **folder CRUD /
+new-doc-in-folder**); **Organizations** (browse + create + **full member
+management**: add via search, change role, remove, edit/delete org);
+**Settings** (profile edit, avatar-from-URL, email change, notification
+preferences, blocked/muted management, **API-session list + revoke**, **CSV
+data export**); unified **Search**; and **Connected Accounts** (Bluesky/
+Mastodon/LinkedIn/Twitter linking + cross-post toggles). Still not built:
+Stripe billing UI, register/forgot-password, GitHub issue sync (endpoints work
+but the test account has no GitHub linked), per-list schema/column definitions,
+LinkedIn per-page posting targets, scheduled-post UI (the service supports
+`scheduledAt`), media *video* upload, list watchers/sharing, document sharing/
+collaborators, Materialize ("Create from…"), and account deletion UI (the
+service method exists, intentionally unsurfaced).
+
+**Load-bearing constraints discovered by live-probing the API — don't
"fix" these without re-verifying, they're not bugs in this app:**
-1. **Not every endpoint accepts the bearer sync-token.** `GET/POST
- /api/organizations/{id}/members` and `GET /api/linkedin/targets` /
- `posting-targets` return `401` even with a valid token — that subsystem
- requires cookie-session auth this native client doesn't have. This is why
- Organizations has no member-management UI and Connected Accounts has no
- LinkedIn per-page targeting.
+1. **A few endpoints only accept cookie-session auth, not the bearer
+ sync-token.** Re-probed live 2026-07-31 with the test account:
+ `GET /api/user/engagement` and `GET/PUT /api/user/dashboard-layout` return
+ `401` with a valid bearer token (Stripe billing + some `/api/auth/*` session
+ flows are the same shape). A native bearer-token client structurally can't
+ get a cookie session, so those are either browser-handoff (like OAuth) or
+ out of scope. **Correction to an earlier claim:** `GET
+ /api/organizations/{id}/members`, `GET /api/linkedin/targets`, and
+ `GET /api/linkedin/posting-targets` were *previously* documented here as
+ `401`-walled, but as of 2026-07-31 they return `200` with the bearer token —
+ member-management and LinkedIn per-page targeting **are** buildable now.
+ (Member *mutations* — POST/PUT/DELETE — still need live write-verification.)
2. **The per-provider `GET /api/auth/{provider}/status` endpoints are a red
herring** — they report whether the *server* has that OAuth integration
configured, not whether *this user* has linked it. The real per-user link
@@ -117,11 +138,13 @@ engineered and is **not implemented** — data rows work fine schema-less
verify the actual response shape against a real (test) account before typing
it strictly, and prefer read-after-write over trusting an unverified envelope.
-Left nav maps to real views now: **Feed**, **Lists**, **Documents**,
-**Organizations**, **Search** (`MainWindow.xaml.cs` `NavItem_Click` swaps a
-`ContentControl` via a small per-tag cache in `_views`), **Accounts**
-(Connected Accounts), and **Alerts** (unchanged from Phase 2 — still a
-right-rail toggle, not a center view).
+Left nav maps to real views now: **Feed**, **Messages** (Direct Messages),
+**Lists**, **Documents**, **Organizations**, **People** (profiles + follow),
+**Search** (`MainWindow.xaml.cs` `NavItem_Click` swaps a `ContentControl` via a
+small per-tag cache in `_views`), **Accounts** (Connected Accounts),
+**Settings**, and **Alerts** (right-rail toggle, not a center view). Feed/search
+cards open a profile in the People tab via the `Navigator` hub
+(`Services/Navigator.cs`) → `MainWindow.OpenProfile`.
## Packaging & distribution
diff --git a/InterlinedList/MainWindow.xaml b/InterlinedList/MainWindow.xaml
index 56a3478..81734a4 100644
--- a/InterlinedList/MainWindow.xaml
+++ b/InterlinedList/MainWindow.xaml
@@ -97,16 +97,22 @@
+
+
+
@@ -232,6 +238,18 @@
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"/>
+
+
+
+
diff --git a/InterlinedList/MainWindow.xaml.cs b/InterlinedList/MainWindow.xaml.cs
index 89c5760..ad0a571 100644
--- a/InterlinedList/MainWindow.xaml.cs
+++ b/InterlinedList/MainWindow.xaml.cs
@@ -24,6 +24,9 @@ public MainWindow()
Profile.LoggedOut += (_, _) => LoggedOut?.Invoke(this, EventArgs.Empty);
+ // Feed/search cards raise this to open a user's profile in the People tab.
+ Navigator.OnOpenProfile = OpenProfile;
+
StartClock();
_ = Notifications.LoadCommand.ExecuteAsync(null);
@@ -60,6 +63,16 @@ private void NavItem_Click(object sender, RoutedEventArgs e)
CenterContent.Content = ViewFor(tag);
}
+ // Navigator.OnOpenProfile → switch to the People tab and load the given user.
+ private void OpenProfile(string username)
+ {
+ NotificationsRail.Visibility = Visibility.Collapsed;
+ ProfileRail.Visibility = Visibility.Visible;
+ var view = (Views.PeopleView)ViewFor("People");
+ CenterContent.Content = view;
+ view.LoadProfile(username);
+ }
+
private UserControl ViewFor(string tag)
{
if (_views.TryGetValue(tag, out var existing)) return existing;
@@ -67,11 +80,14 @@ private UserControl ViewFor(string tag)
UserControl view = tag switch
{
"Feed" => new FeedView(),
+ "Messages" => new DirectMessagesView(),
"Lists" => new ListsView(),
"Documents" => new DocumentsView(),
"Organizations" => new OrganizationsView(),
+ "People" => new PeopleView(),
"Search" => new SearchView(),
"Accounts" => new ConnectedAccountsView(),
+ "Settings" => new SettingsView(),
_ => new FeedView(),
};
diff --git a/InterlinedList/Models/ApiSession.cs b/InterlinedList/Models/ApiSession.cs
new file mode 100644
index 0000000..ae8ef48
--- /dev/null
+++ b/InterlinedList/Models/ApiSession.cs
@@ -0,0 +1,19 @@
+namespace InterlinedList.Models;
+
+///
+/// An active sync-token / API session from GET /api/user/sessions. Revocable via
+/// DELETE /api/user/sessions/{id} (verified live 2026-07-31 — the endpoint the
+/// app itself relies on to let a user cut off a lost device's standing token).
+///
+public sealed class ApiSession
+{
+ public required string Id { get; init; }
+ public string? DeviceLabel { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public DateTimeOffset? LastUsedAt { get; init; }
+ public bool IsCurrent { get; init; }
+
+ public string DeviceLabelOrFallback => string.IsNullOrWhiteSpace(DeviceLabel) ? "Unknown device" : DeviceLabel;
+ public string CreatedFormatted => CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
+ public string LastUsedFormatted => LastUsedAt?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
+}
diff --git a/InterlinedList/Models/DirectMessage.cs b/InterlinedList/Models/DirectMessage.cs
new file mode 100644
index 0000000..d7f09bf
--- /dev/null
+++ b/InterlinedList/Models/DirectMessage.cs
@@ -0,0 +1,21 @@
+namespace InterlinedList.Models;
+
+///
+/// A single 1:1 direct message (matches the OpenAPI DirectMessage schema).
+/// Soft-delete is per-side: sender/recipientDeletedAt hide it for only one
+/// participant. ReadAt is set once the recipient opens the thread.
+///
+public sealed class DirectMessage
+{
+ public required string Id { get; init; }
+ public required string SenderId { get; init; }
+ public required string RecipientId { get; init; }
+ public required string Body { get; init; }
+ public List? ImageUrls { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public DateTimeOffset? ReadAt { get; init; }
+ public DateTimeOffset? SenderDeletedAt { get; init; }
+ public DateTimeOffset? RecipientDeletedAt { get; init; }
+
+ public string TimeFormatted => CreatedAt.ToUniversalTime().ToString("HH:mm:ss'Z'");
+}
diff --git a/InterlinedList/Models/DmRecipient.cs b/InterlinedList/Models/DmRecipient.cs
new file mode 100644
index 0000000..3dd6de5
--- /dev/null
+++ b/InterlinedList/Models/DmRecipient.cs
@@ -0,0 +1,16 @@
+namespace InterlinedList.Models;
+
+///
+/// A person the current user can DM (GET /api/dm/recipients) and the
+/// "otherUser" identity embedded in a thread payload.
+///
+public sealed class DmRecipient
+{
+ public required string Id { get; init; }
+ public required string Username { get; init; }
+ public string? DisplayName { get; init; }
+ public string? Avatar { get; init; }
+
+ public string DisplayNameOrUsername => DisplayName ?? Username;
+ public string Handle => $"@{Username}";
+}
diff --git a/InterlinedList/Models/DmThread.cs b/InterlinedList/Models/DmThread.cs
new file mode 100644
index 0000000..053a30d
--- /dev/null
+++ b/InterlinedList/Models/DmThread.cs
@@ -0,0 +1,14 @@
+namespace InterlinedList.Models;
+
+///
+/// GET /api/dm/thread/{username} — the conversation with one person, oldest
+/// page first. OlderCursor paginates backward into history.
+///
+public sealed class DmThread
+{
+ public required List Items { get; init; }
+ public DmRecipient? OtherUser { get; init; }
+ public bool IsBlocked { get; init; }
+ public bool IsMutual { get; init; }
+ public string? OlderCursor { get; init; }
+}
diff --git a/InterlinedList/Models/FollowStatus.cs b/InterlinedList/Models/FollowStatus.cs
new file mode 100644
index 0000000..13968a5
--- /dev/null
+++ b/InterlinedList/Models/FollowStatus.cs
@@ -0,0 +1,13 @@
+namespace InterlinedList.Models;
+
+///
+/// GET /api/follow/{userId}/status — the caller's relationship to a target user.
+/// Status is "approved" / "pending" / null; IsPending flags a follow request the
+/// caller has sent that the (private) target hasn't approved yet.
+///
+public sealed class FollowStatus
+{
+ public string? Status { get; init; }
+ public bool IsFollowing { get; init; }
+ public bool IsPending { get; init; }
+}
diff --git a/InterlinedList/Models/FollowUser.cs b/InterlinedList/Models/FollowUser.cs
new file mode 100644
index 0000000..9092a1a
--- /dev/null
+++ b/InterlinedList/Models/FollowUser.cs
@@ -0,0 +1,20 @@
+namespace InterlinedList.Models;
+
+///
+/// A user entry in a followers / following / follow-requests list.
+/// FollowId + Status are present on relationship-scoped lists (a follower row
+/// carries the follow edge's id so it can be approved/rejected/removed).
+///
+public sealed class FollowUser
+{
+ public required string Id { get; init; }
+ public required string Username { get; init; }
+ public string? DisplayName { get; init; }
+ public string? Avatar { get; init; }
+ public string? FollowId { get; init; }
+ public string? Status { get; init; }
+ public DateTimeOffset? CreatedAt { get; init; }
+
+ public string DisplayNameOrUsername => DisplayName ?? Username;
+ public string Handle => $"@{Username}";
+}
diff --git a/InterlinedList/Models/ModeratedUser.cs b/InterlinedList/Models/ModeratedUser.cs
new file mode 100644
index 0000000..1747b9f
--- /dev/null
+++ b/InterlinedList/Models/ModeratedUser.cs
@@ -0,0 +1,17 @@
+namespace InterlinedList.Models;
+
+///
+/// A user entry in the current user's block list (GET /api/user/blocks →
+/// blockedUsers[]) or mute list (GET /api/user/mutes → mutedUsers[]).
+///
+public sealed class ModeratedUser
+{
+ public required string Id { get; init; }
+ public required string Username { get; init; }
+ public string? DisplayName { get; init; }
+ public string? Avatar { get; init; }
+ public DateTimeOffset? CreatedAt { get; init; }
+
+ public string DisplayNameOrUsername => DisplayName ?? Username;
+ public string Handle => $"@{Username}";
+}
diff --git a/InterlinedList/Models/NotificationPreference.cs b/InterlinedList/Models/NotificationPreference.cs
new file mode 100644
index 0000000..3c8cdaf
--- /dev/null
+++ b/InterlinedList/Models/NotificationPreference.cs
@@ -0,0 +1,20 @@
+namespace InterlinedList.Models;
+
+///
+/// One row of GET /api/user/notification-preferences ("events"): a notifiable
+/// event and which delivery channels are enabled for it. PATCH the same
+/// endpoint to change a channel toggle.
+///
+public sealed class NotificationPreference
+{
+ public required string Key { get; init; }
+ public required string Label { get; init; }
+ public string? Description { get; init; }
+ public NotificationChannels Channels { get; init; } = new();
+}
+
+public sealed class NotificationChannels
+{
+ public bool Push { get; init; }
+ public bool InApp { get; init; }
+}
diff --git a/InterlinedList/Models/OrgMember.cs b/InterlinedList/Models/OrgMember.cs
new file mode 100644
index 0000000..9df6b96
--- /dev/null
+++ b/InterlinedList/Models/OrgMember.cs
@@ -0,0 +1,22 @@
+namespace InterlinedList.Models;
+
+///
+/// A member of an organization (GET /api/organizations/{id}/members → members[]).
+/// Role is "owner" / "admin" / "member"; Active flags a soft-removed seat.
+/// This endpoint accepts the bearer token (verified live 2026-07-31 — an earlier
+/// note claimed it was 401-walled; that's no longer true).
+///
+public sealed class OrgMember
+{
+ public required string Id { get; init; }
+ public required string Username { get; init; }
+ public string? DisplayName { get; init; }
+ public string? Avatar { get; init; }
+ public string? Role { get; init; }
+ public bool Active { get; init; }
+ public DateTimeOffset? JoinedAt { get; init; }
+
+ public string DisplayNameOrUsername => DisplayName ?? Username;
+ public string Handle => $"@{Username}";
+ public string RoleLabel => string.IsNullOrEmpty(Role) ? "member" : Role;
+}
diff --git a/InterlinedList/Models/UserProfile.cs b/InterlinedList/Models/UserProfile.cs
new file mode 100644
index 0000000..cb52df9
--- /dev/null
+++ b/InterlinedList/Models/UserProfile.cs
@@ -0,0 +1,26 @@
+namespace InterlinedList.Models;
+
+///
+/// Public profile from GET /api/users/{username}. Relationship state
+/// (isFollowing / isBlocked / isMuted) is NOT reliably populated on this
+/// payload — fetch it separately via the follow-status and block/mute
+/// endpoints (verified live 2026-07-31: those fields came back null here).
+///
+public sealed class UserProfile
+{
+ public required string Id { get; init; }
+ public required string Username { get; init; }
+ public string? DisplayName { get; init; }
+ public string? Bio { get; init; }
+ public string? Avatar { get; init; }
+ public string? HeaderImage { get; init; }
+ public bool IsPrivate { get; init; }
+ public DateTimeOffset? JoinedAt { get; init; }
+ public int FollowerCount { get; init; }
+ public int FollowingCount { get; init; }
+ public int PublicListCount { get; init; }
+ public int PublicMessageCount { get; init; }
+
+ public string DisplayNameOrUsername => DisplayName ?? Username;
+ public string Handle => $"@{Username}";
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Account.cs b/InterlinedList/Services/InterlinedApiClient.Account.cs
new file mode 100644
index 0000000..9b7a29b
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Account.cs
@@ -0,0 +1,74 @@
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// Account & settings: profile edit, notification preferences, and API-session
+/// (sync-token) management. GET /api/user/sessions + DELETE
+/// /api/user/sessions/{id} let a user list and revoke standing tokens — the
+/// genuine "sign out this lost device" capability the app previously assumed
+/// didn't exist (verified live 2026-07-31; DELETE is the one destructive call
+/// here and is exercised only against a session the user explicitly picks).
+///
+public sealed partial class InterlinedApiClient
+{
+ public Task UpdateProfileAsync(
+ string? displayName,
+ string? bio,
+ bool? isPrivateAccount = null,
+ string? theme = null,
+ CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Patch, "api/user/update",
+ new { displayName, bio, isPrivateAccount, theme }, ct);
+
+ public async Task> GetSessionsAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/user/sessions", ct);
+ return json.TryGetProperty("sessions", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task RevokeSessionAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/user/sessions/{id}", null, ct);
+
+ public async Task> GetNotificationPreferencesAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/user/notification-preferences", ct);
+ return json.TryGetProperty("events", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ // channels is sent as an object { push, inApp }; the GET returns it that way,
+ // though the OpenAPI request schema loosely types it as string — verify a
+ // real PATCH round-trips before treating this as fully proven.
+ public Task SetNotificationPreferenceAsync(string key, bool push, bool inApp, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Patch, "api/user/notification-preferences",
+ new { key, channels = new { push, inApp } }, ct);
+
+ // ── Avatar / email / account lifecycle ──────────────────────────────────────
+ // Request shapes verified against the OpenAPI spec 2026-07-31: avatar {url},
+ // change-email {newEmail}, delete {username,email} (a self-confirmation guard).
+
+ public Task UpdateAvatarFromUrlAsync(string url, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/user/avatar/from-url", new { url }, ct);
+
+ public async Task UploadAvatarAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
+ {
+ var json = await SendMultipartAsync("api/user/avatar/upload", content, fileName, contentType, ct: ct);
+ return json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } u ? u : "";
+ }
+
+ public Task RequestEmailChangeAsync(string newEmail, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/user/change-email/request", new { newEmail }, ct);
+
+ // Destructive. The body echoes the caller's own username + email as a
+ // confirmation guard the server checks — callers should require the user to
+ // type these before invoking.
+ public Task DeleteAccountAsync(string username, string email, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/user/delete", new { username, email }, ct);
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs b/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs
new file mode 100644
index 0000000..623c5a1
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs
@@ -0,0 +1,42 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// 1:1 direct messages. The MVP conversation list is sourced from
+/// GET /api/dm/recipients (people you can DM); message history comes from the
+/// per-user thread endpoint. NOTE: the POST /api/dm request body is undocumented
+/// in the OpenAPI spec, so uses the inferred
+/// { recipientId, body } shape and is read-after-write — re-fetch the thread and
+/// verify the field names against a real send before depending on it.
+///
+public sealed partial class InterlinedApiClient
+{
+ public async Task> GetDmRecipientsAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/dm/recipients", ct);
+ return json.TryGetProperty("recipients", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task GetDmThreadAsync(string username, CancellationToken ct = default)
+ => GetJsonAsync($"api/dm/thread/{Uri.EscapeDataString(username)}", ct);
+
+ public async Task GetDmUnreadCountAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/dm/unread-count", ct);
+ 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 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);
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Documents.cs b/InterlinedList/Services/InterlinedApiClient.Documents.cs
index 8a41816..6b83da8 100644
--- a/InterlinedList/Services/InterlinedApiClient.Documents.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Documents.cs
@@ -75,4 +75,22 @@ public async Task CreateFromTemplateAsync(string templateDocumentId, Cancellatio
new { templateDocumentId, targetFolderId = (string?)null }, ct);
await EnsureSuccessAsync(resp, ct);
}
+
+ // ── Folder management ───────────────────────────────────────────────────────
+ // GetDocumentFoldersAsync (above) already returns each folder with its
+ // embedded documents; these mutate the folder tree. Request shape { name,
+ // parentId } verified against the OpenAPI spec 2026-07-31.
+
+ public Task CreateDocumentFolderAsync(string name, string? parentId = null, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, "api/documents/folders", new { name, parentId }, ct);
+
+ public Task RenameDocumentFolderAsync(string id, string name, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Put, $"api/documents/folders/{id}", new { name }, ct);
+
+ public Task DeleteDocumentFolderAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/documents/folders/{id}", null, ct);
+
+ 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);
}
diff --git a/InterlinedList/Services/InterlinedApiClient.Exports.cs b/InterlinedList/Services/InterlinedApiClient.Exports.cs
new file mode 100644
index 0000000..e69eb55
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Exports.cs
@@ -0,0 +1,20 @@
+namespace InterlinedList.Services;
+
+///
+/// CSV data exports. Each endpoint returns a raw text/csv body (verified live
+/// 2026-07-31) — callers write it straight to a user-chosen file.
+///
+public sealed partial class InterlinedApiClient
+{
+ public Task ExportMessagesCsvAsync(CancellationToken ct = default)
+ => GetStringAsync("api/exports/messages", ct);
+
+ public Task ExportListsCsvAsync(CancellationToken ct = default)
+ => GetStringAsync("api/exports/lists", ct);
+
+ public Task ExportListDataRowsCsvAsync(CancellationToken ct = default)
+ => GetStringAsync("api/exports/list-data-rows", ct);
+
+ public Task ExportFollowsCsvAsync(CancellationToken ct = default)
+ => GetStringAsync("api/exports/follows", ct);
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Follow.cs b/InterlinedList/Services/InterlinedApiClient.Follow.cs
new file mode 100644
index 0000000..7b3d83c
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Follow.cs
@@ -0,0 +1,54 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// The social graph: follow/unfollow, pending follow requests (for private
+/// accounts), and follower/following/mutual lists. Follow counts live in the
+/// core partial (). All of these accept the
+/// bearer token (verified live 2026-07-31).
+///
+public sealed partial class InterlinedApiClient
+{
+ public Task FollowAsync(string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/follow/{userId}", new { }, ct);
+
+ public Task UnfollowAsync(string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/follow/{userId}", null, ct);
+
+ public Task GetFollowStatusAsync(string userId, CancellationToken ct = default)
+ => GetJsonAsync($"api/follow/{userId}/status", ct);
+
+ public Task> GetFollowRequestsAsync(CancellationToken ct = default)
+ => GetUserArrayAsync("api/follow/requests", "requests", ct);
+
+ public Task ApproveFollowAsync(string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/follow/{userId}/approve", new { }, ct);
+
+ public Task RejectFollowAsync(string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/follow/{userId}/reject", new { }, ct);
+
+ public Task RemoveFollowerAsync(string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/follow/{userId}/remove", null, ct);
+
+ public Task> GetFollowersAsync(string userId, CancellationToken ct = default)
+ => GetUserArrayAsync($"api/follow/{userId}/followers", "followers", ct);
+
+ public Task> GetFollowingAsync(string userId, CancellationToken ct = default)
+ => GetUserArrayAsync($"api/follow/{userId}/following", "following", ct);
+
+ public Task> GetMutualAsync(string userId, CancellationToken ct = default)
+ => GetUserArrayAsync($"api/follow/{userId}/mutual", "mutual", ct);
+
+ // Follow lists wrap their array under different property names
+ // (requests/followers/following/mutual) — pull the named array, tolerate a miss.
+ private async Task> GetUserArrayAsync(string path, string property, CancellationToken ct)
+ {
+ var json = await GetElementAsync(path, ct);
+ return json.TryGetProperty(property, 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 f06069d..89b9522 100644
--- a/InterlinedList/Services/InterlinedApiClient.Lists.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Lists.cs
@@ -50,4 +50,24 @@ public async Task AddListRowAsync(string listId, Dictionary row
using var resp = await SendAsync(HttpMethod.Post, $"api/lists/{listId}/data", new { data = rowData }, ct);
await EnsureSuccessAsync(resp, ct);
}
+
+ // GET /api/lists/{id} returns the list metadata under a "data" envelope
+ // (verified live 2026-07-31).
+ public async Task GetListAsync(string listId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/lists/{listId}", ct);
+ return json.GetProperty("data").Deserialize(JsonOptions)
+ ?? throw new InterlinedApiException(200, "GET /api/lists/{id} returned no data.");
+ }
+
+ public Task UpdateListAsync(string listId, string title, string? description, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}", new { title, description }, ct);
+
+ // Edit/delete of an individual row — the pieces that made rows write-once
+ // before. Same read-after-write discipline as AddListRowAsync.
+ public Task UpdateListRowAsync(string listId, string rowId, Dictionary rowData, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}/data/{rowId}", new { data = rowData }, ct);
+
+ public Task DeleteListRowAsync(string listId, string rowId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/data/{rowId}", null, ct);
}
diff --git a/InterlinedList/Services/InterlinedApiClient.Messages.cs b/InterlinedList/Services/InterlinedApiClient.Messages.cs
new file mode 100644
index 0000000..c0a3635
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Messages.cs
@@ -0,0 +1,60 @@
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// Message depth beyond the feed: single-message detail, replies/threads,
+/// and edit/delete/report of a post. Replies are just POST /api/messages with a
+/// parentId (see ). Edit/delete/report response
+/// envelopes aren't parsed — callers re-fetch (the codebase's read-after-write
+/// pattern) rather than trusting an unverified write body.
+///
+public sealed partial class InterlinedApiClient
+{
+ public async Task GetMessageAsync(string id, CancellationToken ct = default)
+ {
+ // Detail is served either bare or wrapped as { "message": {...} } — accept both.
+ var root = await GetElementAsync($"api/messages/{id}", ct);
+ var el = root.TryGetProperty("message", out var wrapped) && wrapped.ValueKind == JsonValueKind.Object
+ ? wrapped
+ : root;
+ return el.Deserialize(JsonOptions)
+ ?? throw new InterlinedApiException(200, "GET /api/messages/{id} returned no message.");
+ }
+
+ public async Task> GetRepliesAsync(string id, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/messages/{id}/replies", ct);
+ return json.TryGetProperty("replies", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public Task PostReplyAsync(string parentId, string content, bool publiclyVisible, CancellationToken ct = default)
+ => PostMessageAsync(content, publiclyVisible, parentId: parentId, ct: ct);
+
+ public Task EditMessageAsync(string id, string content, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Patch, $"api/messages/{id}", new { content }, ct);
+
+ public Task DeleteMessageAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/messages/{id}", null, ct);
+
+ public Task ReportMessageAsync(string id, string reason, string? detail, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/messages/{id}/report", new { reason, detail }, ct);
+
+ ///
+ /// Upload an image for a post; returns the hosted URL to pass back in the
+ /// message's imageUrls. multipart field "file", response { url } — both
+ /// verified live 2026-07-31. Subscriber-gated (402/403 for free accounts).
+ ///
+ public async Task UploadMessageImageAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
+ {
+ var json = await SendMultipartAsync("api/messages/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, "Image upload returned no url.");
+ }
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Moderation.cs b/InterlinedList/Services/InterlinedApiClient.Moderation.cs
new file mode 100644
index 0000000..49df5ad
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.Moderation.cs
@@ -0,0 +1,56 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// Moderation & safety: block, mute, and report users (report-a-message lives in
+/// the Messages partial). Status probes read the { "blocked": bool } /
+/// { "muted": bool } shapes verified live 2026-07-31. All accept the bearer token.
+///
+public sealed partial class InterlinedApiClient
+{
+ public Task BlockUserAsync(string username, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/users/{Uri.EscapeDataString(username)}/block", new { }, ct);
+
+ public Task UnblockUserAsync(string username, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/users/{Uri.EscapeDataString(username)}/block", null, ct);
+
+ public async Task IsBlockingAsync(string username, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/users/{Uri.EscapeDataString(username)}/block", ct);
+ return json.TryGetProperty("blocked", out var b) && b.ValueKind == JsonValueKind.True;
+ }
+
+ public Task MuteUserAsync(string username, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/users/{Uri.EscapeDataString(username)}/mute", new { }, ct);
+
+ public Task UnmuteUserAsync(string username, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/users/{Uri.EscapeDataString(username)}/mute", null, ct);
+
+ public async Task IsMutingAsync(string username, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/users/{Uri.EscapeDataString(username)}/mute", ct);
+ return json.TryGetProperty("muted", out var m) && m.ValueKind == JsonValueKind.True;
+ }
+
+ public Task ReportUserAsync(string username, string reason, string? detail, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/users/{Uri.EscapeDataString(username)}/report", new { reason, detail }, ct);
+
+ public async Task> GetBlockedUsersAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/user/blocks", ct);
+ return json.TryGetProperty("blockedUsers", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ public async Task> GetMutedUsersAsync(CancellationToken ct = default)
+ {
+ var json = await GetElementAsync("api/user/mutes", ct);
+ return json.TryGetProperty("mutedUsers", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.Organizations.cs b/InterlinedList/Services/InterlinedApiClient.Organizations.cs
index 8c7a93c..afb6121 100644
--- a/InterlinedList/Services/InterlinedApiClient.Organizations.cs
+++ b/InterlinedList/Services/InterlinedApiClient.Organizations.cs
@@ -6,10 +6,11 @@
namespace InterlinedList.Services;
///
-/// Only the top-level organization endpoints (list/get/create) are wired up
-/// here — GET api/organizations/{id}/members returned 401 with this app's
-/// bearer-token auth during live testing, so member-list/management is
-/// deliberately not implemented anywhere in this client.
+/// Organizations: browse (list/get/create) plus full member management. NOTE:
+/// GET api/organizations/{id}/members was previously documented as 401-walled
+/// for bearer auth, but re-probing live 2026-07-31 it (and add/update/remove)
+/// return 200 with the sync-token — so member management IS implemented now.
+/// Member mutations follow the read-after-write pattern (re-fetch members after).
///
public sealed partial class InterlinedApiClient
{
@@ -46,4 +47,30 @@ public async Task CreateOrganizationAsync(string name, string? description, bool
using var resp = await SendAsync(HttpMethod.Post, "api/organizations", new { name, description, isPublic }, ct);
await EnsureSuccessAsync(resp, ct);
}
+
+ public Task UpdateOrganizationAsync(string orgId, string name, string? description, bool isPublic, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Put, $"api/organizations/{orgId}", new { name, description, isPublic }, ct);
+
+ public Task DeleteOrganizationAsync(string orgId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/organizations/{orgId}", null, ct);
+
+ // ── Member management (bearer-authorized as of 2026-07-31) ──────────────────
+
+ public async Task> GetOrgMembersAsync(string orgId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/organizations/{orgId}/members", ct);
+ return json.TryGetProperty("members", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ // Add an existing user (found via the global user search) to the org.
+ public Task AddOrgMemberAsync(string orgId, string userId, string role, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Post, $"api/organizations/{orgId}/members", new { userId, role }, ct);
+
+ public Task UpdateOrgMemberRoleAsync(string orgId, string userId, string role, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Put, $"api/organizations/{orgId}/members/{userId}", new { role }, ct);
+
+ public Task RemoveOrgMemberAsync(string orgId, string userId, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/organizations/{orgId}/members/{userId}", null, ct);
}
diff --git a/InterlinedList/Services/InterlinedApiClient.People.cs b/InterlinedList/Services/InterlinedApiClient.People.cs
new file mode 100644
index 0000000..cebdfaa
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.People.cs
@@ -0,0 +1,20 @@
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// People: a user's public profile and their public content. Relationship state
+/// (follow / block / mute) is NOT on the profile payload — read it from the
+/// follow-status and moderation endpoints (see the Follow/Moderation partials).
+///
+public sealed partial class InterlinedApiClient
+{
+ public Task GetProfileAsync(string username, CancellationToken ct = default)
+ => GetJsonAsync($"api/users/{Uri.EscapeDataString(username)}", ct);
+
+ public Task GetUserMessagesAsync(string username, int limit = 20, int offset = 0, CancellationToken ct = default)
+ => GetJsonAsync($"api/user/{Uri.EscapeDataString(username)}/messages?limit={limit}&offset={offset}", ct);
+
+ public Task GetUserListsAsync(string username, CancellationToken ct = default)
+ => GetJsonAsync($"api/users/{Uri.EscapeDataString(username)}/lists", ct);
+}
diff --git a/InterlinedList/Services/InterlinedApiClient.cs b/InterlinedList/Services/InterlinedApiClient.cs
index 6614acf..c5ce42f 100644
--- a/InterlinedList/Services/InterlinedApiClient.cs
+++ b/InterlinedList/Services/InterlinedApiClient.cs
@@ -1,3 +1,4 @@
+using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
@@ -61,16 +62,27 @@ public async Task PostMessageAsync(
bool publiclyVisible,
bool crossPostToBluesky = false,
bool crossPostToTwitter = false,
+ bool crossPostToLinkedIn = false,
string? mastodonProviderIds = null,
+ string? parentId = null,
+ DateTimeOffset? scheduledAt = null,
+ IReadOnlyList? imageUrls = 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).
using var resp = await SendAsync(HttpMethod.Post, "api/messages", new
{
content,
publiclyVisible,
crossPostToBluesky,
crossPostToTwitter,
- mastodonProviderIds
+ crossPostToLinkedIn,
+ mastodonProviderIds,
+ parentId,
+ scheduledAt = scheduledAt?.UtcDateTime,
+ imageUrls
}, ct);
await EnsureSuccessAsync(resp, ct);
}
@@ -101,6 +113,12 @@ public async Task MarkAllNotificationsReadAsync(CancellationToken ct = default)
await EnsureSuccessAsync(resp, ct);
}
+ public Task MarkNotificationReadAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Patch, $"api/notifications/{id}/read", new { }, ct);
+
+ public Task DeleteNotificationAsync(string id, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete, $"api/notifications/{id}", null, ct);
+
public async Task GetFollowCountsAsync(string userId, CancellationToken ct = default)
{
using var resp = await SendAsync(HttpMethod.Get, $"api/follow/{userId}/counts", body: null, ct);
@@ -109,6 +127,76 @@ public async Task GetFollowCountsAsync(string userId, Cancellation
?? throw new InterlinedApiException((int)resp.StatusCode, "GET /api/follow/{id}/counts returned no body.");
}
+ // ── Shared JSON plumbing (Phase 0) ──────────────────────────────────────────
+ // New domain partials build on these instead of re-hand-rolling the
+ // SendAsync → EnsureSuccess → ReadFromJson dance. Two write helpers exist by
+ // design: SendJsonAsync for live-verified response envelopes, and
+ // SendVoidAsync for the codebase's read-after-write pattern (mutations whose
+ // body shape isn't trusted — the caller re-fetches from a GET afterward).
+
+ private async Task GetJsonAsync(string path, CancellationToken ct)
+ {
+ using var resp = await SendAsync(HttpMethod.Get, path, body: null, ct);
+ await EnsureSuccessAsync(resp, ct);
+ return await resp.Content.ReadFromJsonAsync(JsonOptions, ct)
+ ?? throw new InterlinedApiException((int)resp.StatusCode, $"GET {path} returned no body.");
+ }
+
+ /// Read an endpoint that wraps its payload under a property (e.g. { "lists": [...] }).
+ private async Task GetElementAsync(string path, CancellationToken ct)
+ {
+ using var resp = await SendAsync(HttpMethod.Get, path, body: null, ct);
+ await EnsureSuccessAsync(resp, ct);
+ return await resp.Content.ReadFromJsonAsync(JsonOptions, ct);
+ }
+
+ /// Raw text body — used for CSV export endpoints.
+ private async Task GetStringAsync(string path, CancellationToken ct)
+ {
+ using var resp = await SendAsync(HttpMethod.Get, path, body: null, ct);
+ await EnsureSuccessAsync(resp, ct);
+ return await resp.Content.ReadAsStringAsync(ct);
+ }
+
+ ///
+ /// multipart/form-data upload. The field name is "file" and the response is
+ /// { "url": "..." } for the image endpoints (verified live 2026-07-31).
+ /// Returns the root JSON element so callers can pull whatever key they need.
+ ///
+ private async Task SendMultipartAsync(
+ string path, Stream content, string fileName, string contentType,
+ string fieldName = "file", CancellationToken ct = default)
+ {
+ using var form = new MultipartFormDataContent();
+ var fileContent = new StreamContent(content);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
+ form.Add(fileContent, fieldName, fileName);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = form };
+ if (AccessToken is { Length: > 0 })
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
+
+ using var resp = await _http.SendAsync(request, ct);
+ await EnsureSuccessAsync(resp, ct);
+ return await resp.Content.ReadFromJsonAsync(JsonOptions, ct);
+ }
+
+ /// Mutating call whose response body we don't parse (read-after-write pattern).
+ private async Task SendVoidAsync(HttpMethod method, string path, object? body, CancellationToken ct)
+ {
+ using var resp = await SendAsync(method, path, body, ct);
+ await EnsureSuccessAsync(resp, ct);
+ }
+
+ /// Mutating call whose response envelope IS live-verified and deserialized.
+ private async Task SendJsonAsync(HttpMethod method, string path, object? body, CancellationToken ct)
+ {
+ using var resp = await SendAsync(method, path, body, ct);
+ await EnsureSuccessAsync(resp, ct);
+ return await resp.Content.ReadFromJsonAsync(JsonOptions, ct)
+ ?? throw new InterlinedApiException((int)resp.StatusCode, $"{method} {path} returned no body.");
+ }
+
private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken ct)
{
using var request = new HttpRequestMessage(method, path);
diff --git a/InterlinedList/Services/Navigator.cs b/InterlinedList/Services/Navigator.cs
new file mode 100644
index 0000000..5e05305
--- /dev/null
+++ b/InterlinedList/Services/Navigator.cs
@@ -0,0 +1,18 @@
+namespace InterlinedList.Services;
+
+///
+/// Tiny in-process navigation hub. Lets a feed/search card ask the shell to open
+/// a user's profile without holding a reference to MainWindow. A single settable
+/// callback (not a multicast event) so re-creating the shell on re-login simply
+/// replaces the handler instead of stacking duplicates.
+///
+public static class Navigator
+{
+ public static Action? OnOpenProfile { get; set; }
+
+ public static void OpenProfile(string username)
+ {
+ if (!string.IsNullOrWhiteSpace(username))
+ OnOpenProfile?.Invoke(username);
+ }
+}
diff --git a/InterlinedList/ViewModels/DirectMessagesViewModel.cs b/InterlinedList/ViewModels/DirectMessagesViewModel.cs
new file mode 100644
index 0000000..9f07584
--- /dev/null
+++ b/InterlinedList/ViewModels/DirectMessagesViewModel.cs
@@ -0,0 +1,129 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+public partial class DirectMessagesViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ public ObservableCollection Recipients { get; } = new();
+ public ObservableCollection Messages { get; } = new();
+
+ [ObservableProperty]
+ private DmRecipient? selectedRecipient;
+
+ [ObservableProperty]
+ private string composeText = "";
+
+ [ObservableProperty]
+ private bool isLoading;
+
+ [ObservableProperty]
+ private bool isSending;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ public bool HasSelection => SelectedRecipient is not null;
+
+ public DirectMessagesViewModel(SessionService session)
+ {
+ _session = session;
+ }
+
+ [RelayCommand]
+ private async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ var recipients = await _session.Api.GetDmRecipientsAsync();
+
+ Recipients.Clear();
+ foreach (var recipient in recipients)
+ Recipients.Add(recipient);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task SelectRecipientAsync(DmRecipient recipient)
+ {
+ SelectedRecipient = recipient;
+ await LoadThreadAsync(recipient);
+ }
+
+ private async Task LoadThreadAsync(DmRecipient recipient)
+ {
+ IsLoading = true;
+ try
+ {
+ var currentUserId = _session.CurrentUser?.Id;
+ var thread = await _session.Api.GetDmThreadAsync(recipient.Username);
+
+ Messages.Clear();
+ foreach (var message in thread.Items)
+ Messages.Add(new DmMessageViewModel(message, currentUserId));
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private bool CanSend() =>
+ HasSelection && !IsSending && !string.IsNullOrWhiteSpace(ComposeText);
+
+ // SendDmAsync doesn't return a parsed message body (see
+ // InterlinedApiClient.DirectMessages.cs), so re-fetch the thread after sending.
+ [RelayCommand(CanExecute = nameof(CanSend))]
+ private async Task SendAsync()
+ {
+ if (SelectedRecipient is not { } recipient)
+ return;
+
+ IsSending = true;
+ try
+ {
+ await _session.Api.SendDmAsync(recipient.Id, ComposeText.Trim());
+ ComposeText = "";
+ await LoadThreadAsync(recipient);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsSending = false;
+ }
+ }
+
+ partial void OnSelectedRecipientChanged(DmRecipient? value)
+ {
+ OnPropertyChanged(nameof(HasSelection));
+ SendCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnComposeTextChanged(string value) => SendCommand.NotifyCanExecuteChanged();
+ partial void OnIsSendingChanged(bool value) => SendCommand.NotifyCanExecuteChanged();
+}
diff --git a/InterlinedList/ViewModels/DmMessageViewModel.cs b/InterlinedList/ViewModels/DmMessageViewModel.cs
new file mode 100644
index 0000000..0356ea6
--- /dev/null
+++ b/InterlinedList/ViewModels/DmMessageViewModel.cs
@@ -0,0 +1,23 @@
+using InterlinedList.Models;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// Read-only wrapper around a single for the DM
+/// thread list. Plain properties are enough — a message never mutates in place;
+/// the whole collection is rebuilt on each thread (re-)load.
+///
+public sealed class DmMessageViewModel
+{
+ private readonly DirectMessage _message;
+
+ public DmMessageViewModel(DirectMessage message, string? currentUserId)
+ {
+ _message = message;
+ IsMine = message.SenderId == currentUserId;
+ }
+
+ public string Body => _message.Body;
+ public string TimeFormatted => _message.TimeFormatted;
+ public bool IsMine { get; }
+}
diff --git a/InterlinedList/ViewModels/DocumentsViewModel.cs b/InterlinedList/ViewModels/DocumentsViewModel.cs
index cbc10e4..4aa2c97 100644
--- a/InterlinedList/ViewModels/DocumentsViewModel.cs
+++ b/InterlinedList/ViewModels/DocumentsViewModel.cs
@@ -35,6 +35,24 @@ public partial class DocumentsViewModel : ObservableObject
[ObservableProperty]
private string editContent = "";
+ // ── Folder management ─────────────────────────────────────────
+ [ObservableProperty]
+ private string newFolderName = "";
+
+ // The folder currently being renamed inline (null when no inline editor is open).
+ [ObservableProperty]
+ private DocumentFolder? editingFolder;
+
+ [ObservableProperty]
+ private string editingFolderName = "";
+
+ // The folder currently receiving a new document inline (null when closed).
+ [ObservableProperty]
+ private DocumentFolder? addingDocFolder;
+
+ [ObservableProperty]
+ private string newFolderDocTitle = "";
+
public DocumentsViewModel(SessionService session)
{
_session = session;
@@ -156,7 +174,132 @@ private async Task UseTemplateAsync(DocumentTemplate template)
}
}
+ // ── Folder management commands ────────────────────────────────
+
+ private bool CanCreateFolder() => !string.IsNullOrWhiteSpace(NewFolderName);
+
+ [RelayCommand(CanExecute = nameof(CanCreateFolder))]
+ private async Task CreateFolderAsync()
+ {
+ try
+ {
+ await _session.Api.CreateDocumentFolderAsync(NewFolderName.Trim());
+ NewFolderName = "";
+ ErrorMessage = null;
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void StartRenameFolder(DocumentFolder folder)
+ {
+ EditingFolder = folder;
+ EditingFolderName = folder.Name;
+ }
+
+ [RelayCommand]
+ private void CancelRenameFolder()
+ {
+ EditingFolder = null;
+ EditingFolderName = "";
+ }
+
+ private bool CanSaveRenameFolder() => EditingFolder is not null && !string.IsNullOrWhiteSpace(EditingFolderName);
+
+ [RelayCommand(CanExecute = nameof(CanSaveRenameFolder))]
+ private async Task SaveRenameFolderAsync()
+ {
+ if (EditingFolder is not { } folder) return;
+
+ try
+ {
+ await _session.Api.RenameDocumentFolderAsync(folder.Id, EditingFolderName.Trim());
+ EditingFolder = null;
+ EditingFolderName = "";
+ ErrorMessage = null;
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteFolderAsync(DocumentFolder folder)
+ {
+ try
+ {
+ await _session.Api.DeleteDocumentFolderAsync(folder.Id);
+ if (EditingFolder == folder)
+ {
+ EditingFolder = null;
+ EditingFolderName = "";
+ }
+ if (AddingDocFolder == folder)
+ {
+ AddingDocFolder = null;
+ NewFolderDocTitle = "";
+ }
+ ErrorMessage = null;
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void StartAddDocToFolder(DocumentFolder folder)
+ {
+ AddingDocFolder = folder;
+ NewFolderDocTitle = "";
+ }
+
+ [RelayCommand]
+ private void CancelAddDocToFolder()
+ {
+ AddingDocFolder = null;
+ NewFolderDocTitle = "";
+ }
+
+ private bool CanSaveDocToFolder() => AddingDocFolder is not null && !string.IsNullOrWhiteSpace(NewFolderDocTitle);
+
+ [RelayCommand(CanExecute = nameof(CanSaveDocToFolder))]
+ private async Task SaveDocToFolderAsync()
+ {
+ if (AddingDocFolder is not { } folder) return;
+
+ try
+ {
+ await _session.Api.CreateDocumentInFolderAsync(folder.Id, NewFolderDocTitle.Trim(), "");
+ AddingDocFolder = null;
+ NewFolderDocTitle = "";
+ ErrorMessage = null;
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
partial void OnNewDocTitleChanged(string value) => CreateDocumentCommand.NotifyCanExecuteChanged();
partial void OnSelectedDocumentChanged(DocumentSummary? value) => SaveDocumentCommand.NotifyCanExecuteChanged();
+
+ partial void OnNewFolderNameChanged(string value) => CreateFolderCommand.NotifyCanExecuteChanged();
+
+ partial void OnEditingFolderChanged(DocumentFolder? value) => SaveRenameFolderCommand.NotifyCanExecuteChanged();
+
+ partial void OnEditingFolderNameChanged(string value) => SaveRenameFolderCommand.NotifyCanExecuteChanged();
+
+ partial void OnAddingDocFolderChanged(DocumentFolder? value) => SaveDocToFolderCommand.NotifyCanExecuteChanged();
+
+ partial void OnNewFolderDocTitleChanged(string value) => SaveDocToFolderCommand.NotifyCanExecuteChanged();
}
diff --git a/InterlinedList/ViewModels/FeedViewModel.cs b/InterlinedList/ViewModels/FeedViewModel.cs
index 03900d4..0e163a9 100644
--- a/InterlinedList/ViewModels/FeedViewModel.cs
+++ b/InterlinedList/ViewModels/FeedViewModel.cs
@@ -1,7 +1,9 @@
using System.Collections.ObjectModel;
+using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Services;
+using Microsoft.Win32;
namespace InterlinedList.ViewModels;
@@ -52,11 +54,60 @@ public partial class FeedViewModel : ObservableObject
[ObservableProperty]
private bool crossPostToMastodon;
+ // Images uploaded for the next post (URLs returned by the upload endpoint).
+ public ObservableCollection AttachedImageUrls { get; } = new();
+
+ [ObservableProperty]
+ private bool isUploadingImage;
+
public FeedViewModel(SessionService session)
{
_session = session;
+ AttachedImageUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged();
}
+ [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.UploadMessageImageAsync(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);
+
[RelayCommand]
private async Task LoadCrossPostOptionsAsync()
{
@@ -127,7 +178,7 @@ private async Task LoadMoreAsync()
}
}
- private bool CanPost() => !IsPosting && !string.IsNullOrWhiteSpace(ComposeText);
+ private bool CanPost() => !IsPosting && (!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0);
[RelayCommand(CanExecute = nameof(CanPost))]
private async Task PostAsync()
@@ -140,11 +191,13 @@ await _session.Api.PostMessageAsync(
_session.CurrentUser?.DefaultPubliclyVisible ?? true,
crossPostToBluesky: CrossPostToBluesky,
crossPostToTwitter: CrossPostToTwitter,
- mastodonProviderIds: CrossPostToMastodon ? MastodonProvider : null);
+ mastodonProviderIds: CrossPostToMastodon ? MastodonProvider : null,
+ imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null);
ComposeText = "";
CrossPostToBluesky = false;
CrossPostToTwitter = false;
CrossPostToMastodon = false;
+ AttachedImageUrls.Clear();
await RefreshAsync();
}
catch (InterlinedApiException ex)
diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs
index dc90a9c..7477aa2 100644
--- a/InterlinedList/ViewModels/ListsViewModel.cs
+++ b/InterlinedList/ViewModels/ListsViewModel.cs
@@ -40,6 +40,17 @@ public partial class ListsViewModel : ObservableObject
[ObservableProperty]
private string? rowErrorMessage;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsEditingRow))]
+ private ListDataRow? editingRow;
+
+ [ObservableProperty]
+ private string editRowJson = "";
+
+ public bool IsEditingRow => EditingRow is not null;
+
+ private static readonly JsonSerializerOptions RowEditJsonOptions = new() { WriteIndented = true };
+
public ListsViewModel(SessionService session)
{
_session = session;
@@ -169,6 +180,67 @@ private async Task AddRowAsync()
}
}
+ [RelayCommand]
+ private void StartEditRow(ListDataRow row)
+ {
+ EditingRow = row;
+ EditRowJson = JsonSerializer.Serialize(row.RowData, RowEditJsonOptions);
+ RowErrorMessage = null;
+ }
+
+ [RelayCommand]
+ private void CancelEditRow()
+ {
+ EditingRow = null;
+ EditRowJson = "";
+ }
+
+ [RelayCommand]
+ private async Task SaveRowEditAsync()
+ {
+ if (SelectedList is not { } list || EditingRow is not { } row) return;
+
+ Dictionary parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize>(EditRowJson)
+ ?? new Dictionary();
+ }
+ catch (JsonException)
+ {
+ RowErrorMessage = "That's not valid JSON.";
+ return;
+ }
+
+ try
+ {
+ await _session.Api.UpdateListRowAsync(list.Id, row.Id, parsed);
+ EditingRow = null;
+ EditRowJson = "";
+ RowErrorMessage = null;
+ await LoadRowsAsync(list);
+ }
+ catch (InterlinedApiException ex)
+ {
+ RowErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteRowAsync(ListDataRow row)
+ {
+ if (SelectedList is not { } list) return;
+ try
+ {
+ await _session.Api.DeleteListRowAsync(list.Id, row.Id);
+ await LoadRowsAsync(list);
+ }
+ catch (InterlinedApiException ex)
+ {
+ RowErrorMessage = ex.Message;
+ }
+ }
+
partial void OnNewListTitleChanged(string value) => CreateListCommand.NotifyCanExecuteChanged();
partial void OnSelectedListChanged(ListSummary? value) => AddRowCommand.NotifyCanExecuteChanged();
diff --git a/InterlinedList/ViewModels/MessageItemViewModel.cs b/InterlinedList/ViewModels/MessageItemViewModel.cs
index dd6fa7a..cd1f081 100644
--- a/InterlinedList/ViewModels/MessageItemViewModel.cs
+++ b/InterlinedList/ViewModels/MessageItemViewModel.cs
@@ -1,3 +1,4 @@
+using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Models;
@@ -8,19 +9,33 @@ namespace InterlinedList.ViewModels;
///
/// Wraps a single Message for display/interaction in the feed. DigCount/DugByMe
/// are mutable, observable copies (the underlying Message is immutable) so the
-/// Dig command can update them optimistically.
+/// Dig command can update them optimistically. Content is observable too, so an
+/// in-place edit reflects immediately. Edit/Delete/Report/Reply follow the
+/// codebase's read-after-write discipline — the server write is fire-then-trust
+/// for text (edit) or re-fetch (replies).
///
public partial class MessageItemViewModel : ObservableObject
{
private readonly InterlinedApiClient _api;
+ private readonly string? _currentUserId;
+ private readonly bool _publiclyVisible;
public string Id { get; }
- public string Content { get; }
public string TimeFormatted { get; }
public string AuthorDisplayName { get; }
public string AuthorHandle { get; }
+ public string? AuthorUsername { get; }
public string? AvatarUrl { get; }
public bool IsMine { get; }
+ public bool CanReport => !IsMine;
+
+ public IReadOnlyList ImageUrls { get; }
+ public bool HasImages => ImageUrls.Count > 0;
+
+ public ObservableCollection Replies { get; } = new();
+
+ [ObservableProperty]
+ private string content;
[ObservableProperty]
private int digCount;
@@ -31,20 +46,42 @@ public partial class MessageItemViewModel : ObservableObject
[ObservableProperty]
private bool isBusy;
+ [ObservableProperty]
+ private bool isDeleted;
+
+ [ObservableProperty]
+ private bool isEditing;
+
+ [ObservableProperty]
+ private string editText = "";
+
+ [ObservableProperty]
+ private bool areRepliesVisible;
+
+ [ObservableProperty]
+ private bool isComposingReply;
+
+ [ObservableProperty]
+ private string replyText = "";
+
[ObservableProperty]
private string? errorMessage;
public MessageItemViewModel(Message message, InterlinedApiClient api, string? currentUserId)
{
_api = api;
+ _currentUserId = currentUserId;
+ _publiclyVisible = message.PubliclyVisible;
Id = message.Id;
- Content = message.Content;
+ content = message.Content;
TimeFormatted = message.TimeFormatted;
AuthorDisplayName = message.AuthorDisplayName;
AuthorHandle = message.AuthorHandle;
+ AuthorUsername = message.User?.Username;
AvatarUrl = message.User?.Avatar;
IsMine = message.UserId == currentUserId;
+ ImageUrls = message.ImageUrls ?? new List();
digCount = message.DigCount;
dugByMe = message.DugByMe;
@@ -91,4 +128,134 @@ private async Task DigAsync()
IsBusy = false;
}
}
+
+ // ── Edit (own message) ──────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private void StartEdit()
+ {
+ EditText = Content;
+ IsEditing = true;
+ }
+
+ [RelayCommand]
+ private void CancelEdit() => IsEditing = false;
+
+ private bool CanSaveEdit() => !string.IsNullOrWhiteSpace(EditText);
+
+ [RelayCommand(CanExecute = nameof(CanSaveEdit))]
+ private async Task SaveEditAsync()
+ {
+ try
+ {
+ var text = EditText.Trim();
+ await _api.EditMessageAsync(Id, text);
+ Content = text;
+ IsEditing = false;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── Delete (own message) ────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task DeleteAsync()
+ {
+ try
+ {
+ await _api.DeleteMessageAsync(Id);
+ IsDeleted = true; // the card collapses via a DataTrigger
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── Report (someone else's message) ─────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task ReportAsync()
+ {
+ try
+ {
+ await _api.ReportMessageAsync(Id, "other", null);
+ ErrorMessage = "Reported. Thanks — our team will take a look.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── Replies / thread ────────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task ToggleRepliesAsync()
+ {
+ if (AreRepliesVisible)
+ {
+ AreRepliesVisible = false;
+ return;
+ }
+
+ try
+ {
+ var replies = await _api.GetRepliesAsync(Id);
+ Replies.Clear();
+ foreach (var reply in replies)
+ Replies.Add(new MessageItemViewModel(reply, _api, _currentUserId));
+ AreRepliesVisible = true;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void StartReply() => IsComposingReply = true;
+
+ [RelayCommand]
+ private void CancelReply()
+ {
+ IsComposingReply = false;
+ ReplyText = "";
+ }
+
+ private bool CanPostReply() => !string.IsNullOrWhiteSpace(ReplyText);
+
+ [RelayCommand(CanExecute = nameof(CanPostReply))]
+ private async Task PostReplyAsync()
+ {
+ try
+ {
+ await _api.PostReplyAsync(Id, ReplyText.Trim(), _publiclyVisible);
+ ReplyText = "";
+ IsComposingReply = false;
+
+ var replies = await _api.GetRepliesAsync(Id);
+ Replies.Clear();
+ foreach (var reply in replies)
+ Replies.Add(new MessageItemViewModel(reply, _api, _currentUserId));
+ AreRepliesVisible = true;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void OpenAuthor()
+ {
+ if (!string.IsNullOrEmpty(AuthorUsername))
+ Navigator.OpenProfile(AuthorUsername);
+ }
+
+ partial void OnEditTextChanged(string value) => SaveEditCommand.NotifyCanExecuteChanged();
+ partial void OnReplyTextChanged(string value) => PostReplyCommand.NotifyCanExecuteChanged();
}
diff --git a/InterlinedList/ViewModels/NotificationPrefViewModel.cs b/InterlinedList/ViewModels/NotificationPrefViewModel.cs
new file mode 100644
index 0000000..6054b75
--- /dev/null
+++ b/InterlinedList/ViewModels/NotificationPrefViewModel.cs
@@ -0,0 +1,58 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// Item view model for a single notification preference row. Two-way binding on
+/// / drives a fire-and-forget PATCH to the
+/// API. Reentrancy during the initial ctor assignment is guarded by
+/// _loaded so setting the initial channel state doesn't immediately POST.
+///
+public partial class NotificationPrefViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+ private readonly bool _loaded;
+
+ public string Key { get; }
+ public string Label { get; }
+ public string? Description { get; }
+
+ [ObservableProperty]
+ private bool push;
+
+ [ObservableProperty]
+ private bool inApp;
+
+ public NotificationPrefViewModel(NotificationPreference pref, SessionService session)
+ {
+ _session = session;
+ Key = pref.Key;
+ Label = pref.Label;
+ Description = pref.Description;
+ push = pref.Channels.Push;
+ inApp = pref.Channels.InApp;
+ _loaded = true;
+ }
+
+ partial void OnPushChanged(bool value) => Persist();
+
+ partial void OnInAppChanged(bool value) => Persist();
+
+ private async void Persist()
+ {
+ if (!_loaded)
+ return;
+
+ try
+ {
+ await _session.Api.SetNotificationPreferenceAsync(Key, Push, InApp);
+ }
+ catch (InterlinedApiException)
+ {
+ // Non-critical — a failed toggle just isn't persisted; swallow so a
+ // transient API error doesn't crash on a fire-and-forget path.
+ }
+ }
+}
diff --git a/InterlinedList/ViewModels/NotificationsViewModel.cs b/InterlinedList/ViewModels/NotificationsViewModel.cs
index 2433077..97bd31d 100644
--- a/InterlinedList/ViewModels/NotificationsViewModel.cs
+++ b/InterlinedList/ViewModels/NotificationsViewModel.cs
@@ -68,4 +68,36 @@ private async Task MarkAllReadAsync()
ErrorMessage = ex.Message;
}
}
+
+ [RelayCommand]
+ private async Task MarkOneReadAsync(NotificationItemViewModel item)
+ {
+ try
+ {
+ await _session.Api.MarkNotificationReadAsync(item.Id);
+ item.MarkRead();
+ UnreadCount = Items.Count(i => i.IsUnread);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteOneAsync(NotificationItemViewModel item)
+ {
+ try
+ {
+ await _session.Api.DeleteNotificationAsync(item.Id);
+ Items.Remove(item);
+ UnreadCount = Items.Count(i => i.IsUnread);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
}
diff --git a/InterlinedList/ViewModels/OrganizationsViewModel.cs b/InterlinedList/ViewModels/OrganizationsViewModel.cs
index ceae97f..370f666 100644
--- a/InterlinedList/ViewModels/OrganizationsViewModel.cs
+++ b/InterlinedList/ViewModels/OrganizationsViewModel.cs
@@ -15,6 +15,12 @@ public partial class OrganizationsViewModel : ObservableObject
public ObservableCollection MyOrganizations { get; } = new();
public ObservableCollection AllOrganizations { get; } = new();
+ // Members of the currently selected organization.
+ public ObservableCollection Members { get; } = new();
+
+ // Results of the "add member" user search.
+ public ObservableCollection UserSearchResults { get; } = new();
+
[ObservableProperty]
private bool isLoading;
@@ -33,6 +39,34 @@ public partial class OrganizationsViewModel : ObservableObject
[ObservableProperty]
private OrganizationSummary? selectedOrganization;
+ // True when the signed-in user's role in the selected org is owner or admin.
+ [ObservableProperty]
+ private bool canManageMembers;
+
+ // True only for the owner (delete-org affordance).
+ [ObservableProperty]
+ private bool canDeleteOrganization;
+
+ // ── Add-member search ───────────────────────────────────────────────────
+ [ObservableProperty]
+ private string memberSearchQuery = "";
+
+ [ObservableProperty]
+ private bool isSearchingUsers;
+
+ // ── Edit-org form (mirrors SelectedOrganization when the panel opens) ────
+ [ObservableProperty]
+ private bool isEditingOrganization;
+
+ [ObservableProperty]
+ private string editOrgName = "";
+
+ [ObservableProperty]
+ private string editOrgDescription = "";
+
+ [ObservableProperty]
+ private bool editOrgIsPublic;
+
public OrganizationsViewModel(SessionService session)
{
_session = session;
@@ -93,13 +127,229 @@ private async Task SelectOrganizationAsync(OrganizationSummary org)
try
{
SelectedOrganization = await _session.Api.GetOrganizationAsync(org.Id);
+ IsEditingOrganization = false;
+ MemberSearchQuery = "";
+ UserSearchResults.Clear();
+ ErrorMessage = null;
+ await LoadMembersAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── Members ─────────────────────────────────────────────────────────────
+
+ // Re-fetch the member list for the selected org and recompute the current
+ // user's management rights. Called after selecting an org and after every
+ // member mutation (read-after-write).
+ private async Task LoadMembersAsync()
+ {
+ Members.Clear();
+ CanManageMembers = false;
+ CanDeleteOrganization = false;
+
+ var org = SelectedOrganization;
+ if (org is null)
+ return;
+
+ try
+ {
+ var members = await _session.Api.GetOrgMembersAsync(org.Id);
+ foreach (var member in members)
+ Members.Add(member);
+
+ var myId = _session.CurrentUser?.Id;
+ var mine = myId is null
+ ? null
+ : Members.FirstOrDefault(m => m.Id == myId);
+ var myRole = mine?.Role;
+ CanManageMembers = myRole is "owner" or "admin";
+ CanDeleteOrganization = myRole is "owner";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RemoveOrgMemberAsync(OrgMember member)
+ {
+ var org = SelectedOrganization;
+ if (org is null || member is null)
+ return;
+
+ try
+ {
+ await _session.Api.RemoveOrgMemberAsync(org.Id, member.Id);
+ await LoadMembersAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task PromoteMemberAsync(OrgMember member)
+ {
+ // member → admin → owner
+ var next = member?.Role switch
+ {
+ "member" => "admin",
+ "admin" => "owner",
+ _ => null,
+ };
+ await SetMemberRoleAsync(member, next);
+ }
+
+ [RelayCommand]
+ private async Task DemoteMemberAsync(OrgMember member)
+ {
+ // owner → admin → member
+ var next = member?.Role switch
+ {
+ "owner" => "admin",
+ "admin" => "member",
+ _ => null,
+ };
+ await SetMemberRoleAsync(member, next);
+ }
+
+ private async Task SetMemberRoleAsync(OrgMember? member, string? role)
+ {
+ var org = SelectedOrganization;
+ if (org is null || member is null || role is null)
+ return;
+
+ try
+ {
+ await _session.Api.UpdateOrgMemberRoleAsync(org.Id, member.Id, role);
+ await LoadMembersAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── Add member (user search → add) ──────────────────────────────────────
+
+ private bool CanSearchUsers() => !string.IsNullOrWhiteSpace(MemberSearchQuery);
+
+ [RelayCommand(CanExecute = nameof(CanSearchUsers))]
+ private async Task SearchUsersAsync()
+ {
+ UserSearchResults.Clear();
+ IsSearchingUsers = true;
+ try
+ {
+ var page = await _session.Api.SearchUsersAsync(MemberSearchQuery.Trim());
+ foreach (var user in page.Users)
+ UserSearchResults.Add(user);
ErrorMessage = null;
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
+ finally
+ {
+ IsSearchingUsers = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddMemberAsync(UserSearchResult user)
+ {
+ var org = SelectedOrganization;
+ if (org is null || user is null)
+ return;
+
+ try
+ {
+ await _session.Api.AddOrgMemberAsync(org.Id, user.Id, "member");
+ MemberSearchQuery = "";
+ UserSearchResults.Clear();
+ await LoadMembersAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── Edit / delete organization ──────────────────────────────────────────
+
+ [RelayCommand]
+ private void BeginEditOrganization()
+ {
+ var org = SelectedOrganization;
+ if (org is null)
+ return;
+
+ EditOrgName = org.Name;
+ EditOrgDescription = org.Description ?? "";
+ EditOrgIsPublic = org.IsPublic;
+ IsEditingOrganization = true;
+ }
+
+ [RelayCommand]
+ private void CancelEditOrganization() => IsEditingOrganization = false;
+
+ private bool CanSaveOrganization() => !string.IsNullOrWhiteSpace(EditOrgName);
+
+ [RelayCommand(CanExecute = nameof(CanSaveOrganization))]
+ private async Task SaveOrganizationAsync()
+ {
+ var org = SelectedOrganization;
+ if (org is null)
+ return;
+
+ try
+ {
+ var description = string.IsNullOrWhiteSpace(EditOrgDescription) ? null : EditOrgDescription.Trim();
+ await _session.Api.UpdateOrganizationAsync(org.Id, EditOrgName.Trim(), description, EditOrgIsPublic);
+ IsEditingOrganization = false;
+ // Read-after-write: re-fetch the org detail and its members.
+ SelectedOrganization = await _session.Api.GetOrganizationAsync(org.Id);
+ await LoadMembersAsync();
+ // Keep the left-pane lists in sync with the edited name/visibility.
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteOrganizationAsync()
+ {
+ var org = SelectedOrganization;
+ if (org is null)
+ return;
+
+ try
+ {
+ await _session.Api.DeleteOrganizationAsync(org.Id);
+ SelectedOrganization = null;
+ IsEditingOrganization = false;
+ Members.Clear();
+ UserSearchResults.Clear();
+ CanManageMembers = false;
+ CanDeleteOrganization = false;
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
}
partial void OnNewOrgNameChanged(string value) => CreateOrganizationCommand.NotifyCanExecuteChanged();
+ partial void OnMemberSearchQueryChanged(string value) => SearchUsersCommand.NotifyCanExecuteChanged();
+ partial void OnEditOrgNameChanged(string value) => SaveOrganizationCommand.NotifyCanExecuteChanged();
}
diff --git a/InterlinedList/ViewModels/ProfileViewModel.cs b/InterlinedList/ViewModels/ProfileViewModel.cs
new file mode 100644
index 0000000..0a5308a
--- /dev/null
+++ b/InterlinedList/ViewModels/ProfileViewModel.cs
@@ -0,0 +1,225 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// Backs the People view: look up any user's public profile, follow/unfollow
+/// them, browse their recent messages, and approve/reject the follow requests
+/// pending on the current (private) account.
+///
+public partial class ProfileViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ public ObservableCollection FollowRequests { get; } = new();
+ public ObservableCollection Messages { get; } = new();
+
+ [ObservableProperty]
+ private string lookupUsername = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasProfile))]
+ private UserProfile? profile;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(FollowButtonText))]
+ private FollowStatus? relationship;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(BlockButtonText))]
+ private bool isBlocking;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(MuteButtonText))]
+ private bool isMuting;
+
+ [ObservableProperty]
+ private bool isLoading;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ public bool HasRequests => FollowRequests.Count > 0;
+
+ public bool HasProfile => Profile is not null;
+
+ public string FollowButtonText =>
+ Relationship?.IsFollowing == true ? "Following"
+ : Relationship?.IsPending == true ? "Requested"
+ : "Follow";
+
+ public string BlockButtonText => IsBlocking ? "Unblock" : "Block";
+
+ public string MuteButtonText => IsMuting ? "Unmute" : "Mute";
+
+ public ProfileViewModel(SessionService session)
+ {
+ _session = session;
+ FollowRequests.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRequests));
+ }
+
+ [RelayCommand]
+ private async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ var requests = await _session.Api.GetFollowRequestsAsync();
+
+ FollowRequests.Clear();
+ foreach (var request in requests)
+ FollowRequests.Add(request);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task LoadProfileAsync()
+ {
+ var username = LookupUsername.Trim().TrimStart('@');
+ if (string.IsNullOrWhiteSpace(username))
+ return;
+
+ IsLoading = true;
+ try
+ {
+ var profile = await _session.Api.GetProfileAsync(username);
+ Profile = profile;
+
+ Relationship = await _session.Api.GetFollowStatusAsync(profile.Id);
+ IsBlocking = await _session.Api.IsBlockingAsync(profile.Username);
+ IsMuting = await _session.Api.IsMutingAsync(profile.Username);
+
+ var page = await _session.Api.GetUserMessagesAsync(profile.Username);
+ Messages.Clear();
+ foreach (var message in page.Messages)
+ Messages.Add(new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id));
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ToggleFollowAsync()
+ {
+ if (Profile is null)
+ return;
+
+ try
+ {
+ if (Relationship?.IsFollowing == true)
+ await _session.Api.UnfollowAsync(Profile.Id);
+ else
+ await _session.Api.FollowAsync(Profile.Id);
+
+ // Write path isn't trusted for its response shape — read back the
+ // relationship and refresh the profile so the counts stay honest.
+ Relationship = await _session.Api.GetFollowStatusAsync(Profile.Id);
+ Profile = await _session.Api.GetProfileAsync(Profile.Username);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ToggleBlockAsync()
+ {
+ if (Profile is null) return;
+ try
+ {
+ if (IsBlocking) await _session.Api.UnblockUserAsync(Profile.Username);
+ else await _session.Api.BlockUserAsync(Profile.Username);
+ IsBlocking = await _session.Api.IsBlockingAsync(Profile.Username);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ToggleMuteAsync()
+ {
+ if (Profile is null) return;
+ try
+ {
+ if (IsMuting) await _session.Api.UnmuteUserAsync(Profile.Username);
+ else await _session.Api.MuteUserAsync(Profile.Username);
+ IsMuting = await _session.Api.IsMutingAsync(Profile.Username);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ReportAsync()
+ {
+ if (Profile is null) return;
+ try
+ {
+ await _session.Api.ReportUserAsync(Profile.Username, "other", null);
+ ErrorMessage = "Reported. Thanks — our team will take a look.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ApproveAsync(FollowUser user)
+ {
+ try
+ {
+ await _session.Api.ApproveFollowAsync(user.Id);
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RejectAsync(FollowUser user)
+ {
+ try
+ {
+ await _session.Api.RejectFollowAsync(user.Id);
+ await LoadAsync();
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+}
diff --git a/InterlinedList/ViewModels/SettingsViewModel.cs b/InterlinedList/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..2656c5e
--- /dev/null
+++ b/InterlinedList/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,251 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+using Microsoft.Win32;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// Settings view model: edit profile, manage standing API sessions (sync-tokens),
+/// notification preferences, and the blocked/muted user lists. Sessions are NOT
+/// auto-loaded (the list can be hundreds of rows) — they load on the Refresh
+/// button via .
+///
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly SessionService _session;
+
+ public ObservableCollection Sessions { get; } = new();
+ public ObservableCollection Preferences { get; } = new();
+ public ObservableCollection BlockedUsers { get; } = new();
+ public ObservableCollection MutedUsers { get; } = new();
+
+ [ObservableProperty]
+ private string displayName = "";
+
+ [ObservableProperty]
+ private string bio = "";
+
+ [ObservableProperty]
+ private bool isPrivateAccount;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string? errorMessage;
+
+ [ObservableProperty]
+ private string avatarUrl = "";
+
+ [ObservableProperty]
+ private string newEmail = "";
+
+ public SettingsViewModel(SessionService session)
+ {
+ _session = session;
+ }
+
+ [RelayCommand]
+ private async Task SetAvatarAsync()
+ {
+ if (string.IsNullOrWhiteSpace(AvatarUrl)) return;
+ try
+ {
+ await _session.Api.UpdateAvatarFromUrlAsync(AvatarUrl.Trim());
+ AvatarUrl = "";
+ ErrorMessage = "Avatar updated.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ChangeEmailAsync()
+ {
+ if (string.IsNullOrWhiteSpace(NewEmail)) return;
+ try
+ {
+ await _session.Api.RequestEmailChangeAsync(NewEmail.Trim());
+ NewEmail = "";
+ ErrorMessage = "Requested — check your inbox to confirm the new address.";
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task LoadAsync()
+ {
+ IsBusy = true;
+ try
+ {
+ // Prefill profile fields from the current user.
+ var user = _session.CurrentUser;
+ if (user is not null)
+ {
+ DisplayName = user.DisplayName ?? "";
+ Bio = user.Bio ?? "";
+ IsPrivateAccount = user.IsPrivateAccount;
+ }
+
+ var prefs = await _session.Api.GetNotificationPreferencesAsync();
+ Preferences.Clear();
+ foreach (var pref in prefs)
+ Preferences.Add(new NotificationPrefViewModel(pref, _session));
+
+ var blocked = await _session.Api.GetBlockedUsersAsync();
+ BlockedUsers.Clear();
+ foreach (var blockedUser in blocked)
+ BlockedUsers.Add(blockedUser);
+
+ var muted = await _session.Api.GetMutedUsersAsync();
+ MutedUsers.Clear();
+ foreach (var mutedUser in muted)
+ MutedUsers.Add(mutedUser);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task LoadSessionsAsync()
+ {
+ IsBusy = true;
+ try
+ {
+ var sessions = await _session.Api.GetSessionsAsync();
+ Sessions.Clear();
+ foreach (var s in sessions)
+ Sessions.Add(s);
+
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task SaveProfileAsync()
+ {
+ IsBusy = true;
+ try
+ {
+ await _session.Api.UpdateProfileAsync(DisplayName, Bio, IsPrivateAccount);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RevokeAsync(ApiSession s)
+ {
+ try
+ {
+ await _session.Api.RevokeSessionAsync(s.Id);
+ Sessions.Remove(s);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task UnblockAsync(ModeratedUser u)
+ {
+ try
+ {
+ await _session.Api.UnblockUserAsync(u.Username);
+ BlockedUsers.Remove(u);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private async Task UnmuteAsync(ModeratedUser u)
+ {
+ try
+ {
+ await _session.Api.UnmuteUserAsync(u.Username);
+ MutedUsers.Remove(u);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ // ── CSV data export ─────────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private Task ExportMessagesAsync() => ExportCsvAsync(_session.Api.ExportMessagesCsvAsync, "messages.csv");
+
+ [RelayCommand]
+ private Task ExportListsAsync() => ExportCsvAsync(_session.Api.ExportListsCsvAsync, "lists.csv");
+
+ [RelayCommand]
+ private Task ExportListRowsAsync() => ExportCsvAsync(_session.Api.ExportListDataRowsCsvAsync, "list-rows.csv");
+
+ [RelayCommand]
+ private Task ExportFollowsAsync() => ExportCsvAsync(_session.Api.ExportFollowsCsvAsync, "follows.csv");
+
+ private async Task ExportCsvAsync(Func> fetch, string defaultFileName)
+ {
+ try
+ {
+ var csv = await fetch(default);
+ var dlg = new SaveFileDialog
+ {
+ FileName = defaultFileName,
+ DefaultExt = ".csv",
+ Filter = "CSV file (*.csv)|*.csv|All files (*.*)|*.*"
+ };
+ if (dlg.ShowDialog() == true)
+ await File.WriteAllTextAsync(dlg.FileName, csv);
+ ErrorMessage = null;
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ catch (IOException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+}
diff --git a/InterlinedList/Views/DirectMessagesView.xaml b/InterlinedList/Views/DirectMessagesView.xaml
new file mode 100644
index 0000000..8d5a87f
--- /dev/null
+++ b/InterlinedList/Views/DirectMessagesView.xaml
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/DirectMessagesView.xaml.cs b/InterlinedList/Views/DirectMessagesView.xaml.cs
new file mode 100644
index 0000000..94411ae
--- /dev/null
+++ b/InterlinedList/Views/DirectMessagesView.xaml.cs
@@ -0,0 +1,15 @@
+using System.Windows.Controls;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+public partial class DirectMessagesView : UserControl
+{
+ public DirectMessagesView()
+ {
+ InitializeComponent();
+ var vm = new DirectMessagesViewModel(Services.AppServices.Session);
+ DataContext = vm;
+ _ = vm.LoadCommand.ExecuteAsync(null);
+ }
+}
diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml
index f7fbde4..89c55a0 100644
--- a/InterlinedList/Views/DocumentsView.xaml
+++ b/InterlinedList/Views/DocumentsView.xaml
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:InterlinedList.ViewModels"
+ xmlns:local="clr-namespace:InterlinedList.Views"
xmlns:models="clr-namespace:InterlinedList.Models"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@@ -11,6 +12,7 @@
+
+
+
+
+
+
+
@@ -144,11 +195,42 @@
VerticalAlignment="Center"/>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -187,6 +269,15 @@
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="4">
+
+
+
@@ -220,12 +311,12 @@
Margin="0,0,8,0">
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 587fdf7..a210194 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -82,6 +82,31 @@
+
+
+
@@ -214,23 +239,63 @@
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="20,12">
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/OrganizationsView.xaml b/InterlinedList/Views/OrganizationsView.xaml
index b766761..76ae811 100644
--- a/InterlinedList/Views/OrganizationsView.xaml
+++ b/InterlinedList/Views/OrganizationsView.xaml
@@ -112,6 +112,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/PeopleView.xaml.cs b/InterlinedList/Views/PeopleView.xaml.cs
new file mode 100644
index 0000000..b0449e6
--- /dev/null
+++ b/InterlinedList/Views/PeopleView.xaml.cs
@@ -0,0 +1,24 @@
+using System.Windows.Controls;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+public partial class PeopleView : UserControl
+{
+ private readonly ProfileViewModel _vm;
+
+ public PeopleView()
+ {
+ InitializeComponent();
+ _vm = new ProfileViewModel(Services.AppServices.Session);
+ DataContext = _vm;
+ _ = _vm.LoadCommand.ExecuteAsync(null);
+ }
+
+ /// Open a specific user's profile (used by shell navigation from a feed/search card).
+ public void LoadProfile(string username)
+ {
+ _vm.LookupUsername = username;
+ _ = _vm.LoadProfileCommand.ExecuteAsync(null);
+ }
+}
diff --git a/InterlinedList/Views/SettingsView.xaml b/InterlinedList/Views/SettingsView.xaml
new file mode 100644
index 0000000..27f0dbd
--- /dev/null
+++ b/InterlinedList/Views/SettingsView.xaml
@@ -0,0 +1,562 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/SettingsView.xaml.cs b/InterlinedList/Views/SettingsView.xaml.cs
new file mode 100644
index 0000000..8bd2fab
--- /dev/null
+++ b/InterlinedList/Views/SettingsView.xaml.cs
@@ -0,0 +1,15 @@
+using System.Windows.Controls;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+public partial class SettingsView : UserControl
+{
+ public SettingsView()
+ {
+ InitializeComponent();
+ var vm = new SettingsViewModel(Services.AppServices.Session);
+ DataContext = vm;
+ _ = vm.LoadCommand.ExecuteAsync(null);
+ }
+}
diff --git a/the-gaps.md b/the-gaps.md
new file mode 100644
index 0000000..3934932
--- /dev/null
+++ b/the-gaps.md
@@ -0,0 +1,435 @@
+# The Gaps — InterlinedList Windows App vs. interlinedlist.com
+
+> **Generated:** 2026-07-31 · **Method:** compared the app's implemented API
+> surface (`InterlinedList/Services/InterlinedApiClient*.cs`) against the live
+> OpenAPI 3.1 spec (`https://interlinedlist.com/api/openapi.json` — **189 paths,
+> 244 operations**) and the product's own help docs (`/help`, `/help/api`).
+>
+> **Headline:** the desktop client implements ~**32** endpoints. The web
+> product exposes on the order of **~190 user-facing operations** (excluding
+> cron/webhooks/admin/internal plumbing). Feature parity is therefore roughly
+> **15–20 %** by endpoint count — the app is a solid *read-mostly* client for a
+> handful of domains (Feed, Lists, Documents, Orgs browse, Search, Connected
+> Accounts) and is missing several whole product pillars: **Direct Messages,
+> the social graph (follow/unfollow), People/profiles, Moderation, Account &
+> Settings, replies/threads, media, exports, GitHub, and billing.**
+
+---
+
+## Progress — Session 1 (2026-07-31)
+
+Live-probed the API with the `.env` test account, then built a large parity
+increment. **The whole app compiles clean** (`dotnet build` on macOS). Shipped:
+
+**Verified against the live API (test account `messenger`):**
+- The account is a **subscriber** (`customerStatus: "subscriber"`) → subscriber-gated
+ features (media upload, creates) are exercisable.
+- **Two CLAUDE.md "constraints" were outdated** and are now corrected: `GET
+ /api/organizations/{id}/members`, `/api/linkedin/targets`, and
+ `/api/linkedin/posting-targets` return **200** with the bearer token (were
+ documented as 401 walls) — Organizations member-management and LinkedIn
+ per-page targeting are now buildable. And the sync-token **does** have a
+ server-side revoke: `GET /api/user/sessions` + `DELETE /api/user/sessions/{id}`
+ (the test account has **502** accumulated stale tokens — a concrete reason to
+ surface this).
+- Genuinely cookie-session-only (401 w/ bearer): `/api/user/engagement`,
+ `/api/user/dashboard-layout`, Stripe billing. Those stay browser-handoff / out
+ of scope.
+
+**Service layer (all compiling, shapes live-verified):** Phase-0 shared HTTP
+helpers; new partials for Messages-depth, People, Follow, Direct Messages,
+Moderation, Account/Sessions, Exports; Lists row edit/delete + metadata; compose
+now supports reply (`parentId`), scheduling (`scheduledAt`), and LinkedIn cross-post.
+
+**UI shipped:**
+- ✅ **Feed depth** — reply, view replies (thread), edit & delete own posts,
+ report others' posts, inline on each card.
+- ✅ **Direct Messages** — new nav item; recipient list + conversation thread +
+ composer (send is read-after-write; POST body shape flagged for verification).
+- ✅ **People** — new nav item; profile lookup, follow/unfollow, follow-request
+ approve/reject, a user's messages, follower/following counts.
+- ✅ **Settings** — new nav item; profile edit, **API-session list + revoke**,
+ notification-preference toggles, blocked/muted lists with unblock/unmute.
+
+**Built but not yet surfaced in UI (service methods exist):** CSV exports
+(4 endpoints), list row edit/delete. **Still open:** see remaining unchecked
+boxes below (media-upload UI, scheduled-post UI, list folders/watchers/sharing,
+doc folders/sharing/collab, org member mgmt UI [now unblocked], Materialize,
+GitHub, billing handoff, register/forgot-password), plus wiring feed/search
+cards to open a profile, and a DM-unread badge on the nav.
+
+---
+
+## Progress — Session 2 (2026-07-31)
+
+Continued toward parity. New live-verified shapes (org members `{members:[{id,
+username,displayName,avatar,role,active,joinedAt}]}`, member add `{userId,role}`,
+folders `{name,parentId}`). **Materialize (`{source}`, opaque) and media upload
+(multipart, undocumented response) were deferred** — they need a live write to
+pin down, unlike everything below.
+
+**Services added (compiling):** org member management (list/add/change-role/remove)
++ org update/delete; document folder CRUD + create-doc-in-folder.
+
+**UI shipped this session:**
+- ✅ **CSV data export** (Settings) — messages / lists / list-rows / follows, each
+ via a native Save-file dialog.
+- ✅ **List rows: edit + delete** (Lists) — inline JSON editor + per-row delete;
+ rows are no longer write-once.
+- ✅ **Block / mute / report a user from their profile** (People).
+- ✅ **Organizations: member management** — list members, add (via user search),
+ change role, remove, edit/delete org (owner/admin gated). *[sub-agent]*
+- ✅ **Documents: folder management** — create/rename/delete folders, new doc in
+ folder. *[sub-agent]*
+
+**Still deferred / open:** media-upload + scheduled-post compose UI, Materialize
+("Create from…"), list folders/watchers/sharing, doc sharing/collaborators,
+GitHub (needs the account to link GitHub first), billing handoff, register/
+forgot-password, profile-navigation from feed cards, DM inbox-folder + polling +
+image attach.
+
+---
+
+## Progress — Session 3 (2026-07-31) — ship-readiness batch
+
+Verified the last write shapes live (image upload: multipart field **`file`** →
+`{url}`; avatar `{url}`; email `{newEmail}`; delete `{username,email}`), then
+built the remaining ship-critical features. **App builds clean in Debug AND
+Release, and the self-contained `win-x64` publish succeeds** (exactly what the
+WiX MSI job harvests).
+
+**Shipped this session:**
+- ✅ **Image attachments on posts** — upload from disk in compose (multipart),
+ pending-thumbnail strip with remove, and images render in feed cards.
+- ✅ **Profile navigation** — author names in the feed are clickable → open that
+ user in the People tab (via a new `Navigator` hub).
+- ✅ **Granular notifications** — mark-one-read + delete-one in the Alerts rail.
+- ✅ **Account settings** — avatar-from-URL + email-change request in Settings.
+- Services: multipart helper, message image upload, avatar/email/delete-account,
+ notification mark-one/delete. (`DeleteAccountAsync` exists but is intentionally
+ **not** surfaced — destructive + untestable.)
+
+Feature set is now broad enough to ship as a capable native client. Remaining
+items (scheduled-post UI, video upload, list/doc sharing, Materialize, GitHub,
+billing, register/forgot-password) are tracked below as post-v1.
+
+---
+
+## 1. Parity snapshot by domain
+
+| Domain (product's name) | Web/API has | App has today | Status |
+|---|---|---|---|
+| **Messages / Feed** | feed, post, dig, search, **replies/threads, edit, delete, report, link-unfurl, scheduled, image+video upload, per-user timeline** | feed, post, dig/undig, search | 🟡 Partial |
+| **Direct Messages** | full 1:1 DM: inbox, threads, send, read/unread, trash/restore, image attach | — nothing | 🔴 Missing |
+| **People / Profiles** | public profile, user lookup, a user's messages/lists/documents | — (search returns users but no profile view) | 🔴 Missing |
+| **Social graph (Follow)** | follow/unfollow, requests, approve/reject, remove follower, status, followers/following/mutual, counts | counts only | 🔴 Missing (read-only stub) |
+| **Blocking / Muting / Reporting** | block, mute, report user, report message, list blocks/mutes | — nothing | 🔴 Missing |
+| **Lists** | browse, create, delete, rows, **edit/delete row, single-list metadata, update, schema DSL, folders, watchers/sharing, share-links, connections, contributors, watching, GitHub refresh** | browse, create, delete, view rows, add row, search | 🟡 Partial |
+| **Documents** | CRUD, templates, search, **folder CRUD, tree, delta-sync, images, share-links, collaborators, presence/live cursors, seed-defaults** | root list, folders (read), templates (read), CRUD, from-template, search | 🟡 Partial |
+| **Organizations** | browse, create, get, **update, delete, members mgmt, org users, LinkedIn org integration** | browse (public + mine), get, create | 🟡 Partial (some member endpoints are cookie-auth-only) |
+| **Notifications** | tray, mark-all-read, **mark-one, delete-one, preferences** | tray, mark-all-read | 🟡 Partial |
+| **Account & Security / Settings** | profile edit, avatar, change-email, delete account, **sessions/token revoke**, notification prefs, dashboard/front-wall layout, engagement, identities verify | — (identities list/remove only) | 🔴 Missing |
+| **Auth flows** | login (sync-token), register, forgot/reset password, verify-email, logout, multi-account switch | sync-token login only | 🟡 Partial |
+| **Cross-Platform Syndication** | link/unlink identities, compose-time cross-post toggles (Mastodon/Bluesky/LinkedIn/Twitter) | identities list/remove, browser OAuth handoff, compose toggles | 🟢 Mostly done (LinkedIn per-page targeting blocked by auth model) |
+| **Create from… (Materialize)** | turn message(s) into a List/Document | — nothing | 🔴 Missing |
+| **Exporting Data** | CSV export: messages, lists, list-rows, follows | — nothing | 🔴 Missing |
+| **GitHub integration** | repos, issues (list/create/update/comment), assignees, labels | — nothing | 🔴 Missing |
+| **Billing / Subscription** | Stripe checkout + portal; subscriber-gated features | — nothing (can't see plan or upgrade) | 🔴 Missing |
+| **Dashboard / Widgets** | dashboard & front-wall layout, weather/location, markets/news/bike-share widgets | — nothing | 🔴 Missing (low priority for desktop) |
+| **Search** | messages, people, lists, documents | all four | 🟢 Done |
+| **Admin** | blog + user administration | — nothing | ⚪ Out of scope (admin-only) |
+
+Legend: 🟢 done · 🟡 partial · 🔴 missing · ⚪ intentionally out of scope
+
+---
+
+## 2. The gap list (prioritized)
+
+Each item cites the real endpoint(s). Tiers reflect user impact for a desktop
+client, not raw endpoint count.
+
+### P0 — Core social pillars that make the app feel incomplete without them
+
+- [x] **Replies / threads** — ✅ read + post replies inline on each feed card
+ (`GET /api/messages/{id}/replies`, reply via `POST /api/messages` w/ parentId).
+- [x] **Edit / delete your own messages** — ✅ inline on own cards
+ (`PATCH`/`DELETE /api/messages/{id}`).
+- [x] **Follow / unfollow + requests** — ✅ People view: follow/unfollow,
+ request approve/reject, status, followers/following. (`.../mutual` service
+ exists, not yet surfaced.)
+- [x] **People / profile view** — ✅ People view (lookup + profile + their
+ messages + counts). *Remaining:* open a profile directly from a feed/search
+ card (nav plumbing), and their public lists/documents tabs.
+- [x] **Direct Messages** — ✅ MVP shipped (recipient list + thread + send + mark
+ read). *Remaining:* the `/api/dm` inbox-folder view, `.../updates` polling,
+ trash/restore UI, image attachments; verify the `POST /api/dm` body shape live.
+
+### P1 — Expected table-stakes for a "real" client
+
+- [x] **Moderation: block / mute / report** — ✅ report a message (feed),
+ ✅ blocked/muted lists (Settings), ✅ block/mute/report a user from their
+ profile (People). All shipped.
+- [~] **Account & Settings** — ✅ profile edit (`PATCH /api/user/update`) and
+ notification preferences (`GET`/`PATCH`). *Remaining:* avatar upload
+ (`POST /api/user/avatar/*`, needs multipart), email change, delete account.
+- [x] **Session / token management** — ✅ **VERIFIED REAL & SHIPPED.** Settings
+ lists active sync-tokens and revokes them (`GET /api/user/sessions`,
+ `DELETE /api/user/sessions/{id}`). CLAUDE.md corrected — the "no revoke
+ endpoint" claim was stale. (Test account had 502 stale tokens.)
+- [x] **List rows: edit + delete** — ✅ shipped: inline JSON editor + per-row
+ delete in ListsView (`UpdateListRowAsync`/`DeleteListRowAsync`).
+- [x] **List metadata & update** — service done (`GetListAsync`/`UpdateListAsync`).
+- [ ] **Media attachments on posts** — `POST /api/messages/images/upload`,
+ `POST /api/messages/videos/upload` (subscriber-gated; multipart — deferred).
+- [ ] **Notifications: granular** — `PATCH /api/notifications/{id}/read`,
+ `DELETE /api/notifications/{id}`.
+- [x] **Data export** — ✅ shipped: Settings → messages/lists/list-rows/follows
+ as CSV via Save-file dialog (`GET /api/exports/*`).
+
+### P2 — Depth features that unlock collaboration & organization
+
+- [ ] **Scheduled messages** — `GET /api/messages/scheduled` + scheduled-post
+ option on `POST /api/messages`.
+- [ ] **List folders** — `GET`/`POST`/`PUT`/`DELETE /api/folders`.
+- [ ] **List watchers / sharing** — `GET`/`POST /api/lists/{id}/watchers`,
+ `.../me`, `.../users`, `PUT`/`DELETE .../watchers/{userId}`;
+ share-links `GET`/`POST /api/lists/{id}/share-links`, `DELETE .../{token}`;
+ resolve `GET`/`POST /api/lists/shared/{token}`, `.../data`;
+ `GET /api/lists/watching`, `GET /api/lists/{id}/contributors`.
+- [ ] **List connections** — `GET`/`POST /api/lists/connections`,
+ `DELETE /api/lists/connections/{id}`.
+- [~] **Document folders (full CRUD) + tree** — ✅ create/rename/delete folder +
+ new-doc-in-folder shipped in DocumentsView. *Remaining:* `GET /api/documents/tree`,
+ move/reparent.
+- [ ] **Document sharing & collaboration** — share-links
+ `GET`/`POST`/`DELETE /api/documents/{id}/share-links`;
+ collaborators `GET`/`POST /api/documents/{id}/collaborators`, `.../users`,
+ `PUT`/`DELETE .../{userId}`; images `POST /api/documents/{id}/images/upload`.
+- [ ] **"Create from…" (Materialize)** — `POST /api/materialize` (message → List/Document).
+- [ ] **Message link unfurl** — `POST /api/messages/{id}/metadata`.
+
+### P3 — Nice-to-have / platform-gated / lower desktop value
+
+- [x] **Organizations: manage** — ✅ shipped: member list/add/change-role/remove
+ + edit/delete org (owner/admin gated) in OrganizationsView.
+ **Correction:** `.../members` is **not** cookie-session-only — it returns
+ 200 with the bearer token (re-verified 2026-07-31); the old 401 note was stale.
+- [ ] **GitHub integration** — `GET /api/github/repos|issues`, `POST /api/github/issues`,
+ `PATCH /api/github/issues/{owner}/{repo}/{number}`, comments, assignees, labels.
+- [ ] **Billing / subscription** — `POST /api/stripe/create-checkout-session`,
+ `create-portal-session` (both cookie-session-only → likely browser handoff).
+- [ ] **Auth self-service** — register / forgot-password / reset-password /
+ verify-email / logout / multi-account switch (`/api/auth/*`).
+- [ ] **Document delta-sync** — `GET`/`POST /api/documents/sync` (offline/merge; big lift).
+- [ ] **Live presence / cursors in documents** — `POST`/`DELETE /api/documents/{id}/presence`.
+- [ ] **Dashboard / front-wall layout + widgets** — `GET`/`PUT /api/user/dashboard-layout`,
+ `.../front-wall-layout`, `/api/widgets/*`, `/api/weather`, `/api/location`,
+ `GET /api/user/engagement`.
+
+### ⚪ Deliberately excluded (not client features)
+Cron (`/api/cron/*`), webhooks (`/api/webhooks/*`), admin (`/api/admin/*`),
+`test-db`, `analytics/ingest`, `architecture-aggregates`, `oauth/client-metadata`,
+`images/proxy`, `openapi.json`, push device registration (`/api/push/*` — mobile).
+
+---
+
+## 3. Implementation plan
+
+The plan closes gaps in **priority order**, front-loading a small amount of
+reusable plumbing so each subsequent domain is cheap. Every phase follows the
+codebase's existing conventions: one partial-class file per domain in
+`Services/`, a `ViewModel` per view (`CommunityToolkit.Mvvm`), a self-contained
+`UserControl` in `Views/` that news up its own VM from `AppServices.Session`,
+and Strata design tokens only. **Read-after-write** stays the default for any
+write whose response envelope isn't live-verified (§5).
+
+### Phase 0 — Shared plumbing (do first, ~small)
+
+Nothing user-visible; makes everything after it faster and consistent.
+
+1. **Generic paginated GET helper + typed error surfacing.** The five domain
+ partials each hand-roll `SendAsync → EnsureSuccessAsync → ReadFromJsonAsync`.
+ Extract `GetJsonAsync(path)` / `PostJsonAsync(path, body)` helpers on
+ `InterlinedApiClient` so new endpoints are one line.
+2. **Multipart upload helper** for the four image/video upload endpoints
+ (`SendMultipartAsync(path, stream, fileName, contentType)`). Needed by DM,
+ messages, documents, avatar.
+3. **Profile navigation contract.** Add a lightweight `NavigateToProfile(username)`
+ hook on the shell (`MainWindow`) so feed cards, search results, DM threads,
+ and follower lists can all open a `ProfileView` (built in Phase 2).
+4. **New models** land in `Models/` per domain as each phase needs them (wire
+ types matching real JSON — keep the "don't strictly type unverified write
+ envelopes" rule).
+
+### Phase 1 — Complete the Feed (P0 messages depth)
+
+*Endpoints:* `GET /api/messages/{id}`, `GET /api/messages/{id}/replies`,
+`POST /api/messages/{id}/reply-counts`, `PATCH`/`DELETE /api/messages/{id}`,
+`POST /api/messages/{id}/report`, `POST /api/messages/{id}/metadata`,
+`POST /api/messages/images/upload` / `videos/upload`, `GET /api/messages/scheduled`.
+
+- **Services:** extend `InterlinedApiClient.cs` (or a new `.Messages.cs` partial)
+ with `GetMessageAsync`, `GetRepliesAsync`, `PostReplyAsync` (POST with parent
+ id), `EditMessageAsync`, `DeleteMessageAsync`, `ReportMessageAsync`,
+ `FetchLinkMetadataAsync`, `UploadMessageImageAsync`, `GetScheduledAsync`.
+- **ViewModels/Views:** add a **thread/detail view** (message + reply list +
+ inline reply composer) reachable by clicking a feed card; add edit/delete/report
+ affordances to `MessageItemViewModel`; add an image-attach picker + scheduled-post
+ toggle to the composer in `FeedViewModel`.
+- **Design:** replies indent under the parent; the amber "Dug" left-edge rule
+ already exists — reuse it. Report uses a small confirm dialog (no browser modal
+ dialogs).
+- **Risks:** reply payload shape and `reply-counts` body must be live-probed;
+ media upload is subscriber-gated → handle `402/403` gracefully.
+
+### Phase 2 — People & the social graph (P0)
+
+*Endpoints:* `GET /api/users/{username}`, `GET /api/user/{username}/messages`,
+`GET /api/users/{username}/lists|documents`, `GET /api/users/lookup`;
+all of `/api/follow/*`.
+
+- **Services:** new `InterlinedApiClient.People.cs` (profile fetch + a user's
+ public content) and `InterlinedApiClient.Follow.cs` (follow/unfollow, requests,
+ approve/reject, remove, status, followers/following/mutual — counts already
+ exist, move it here).
+- **ViewModels/Views:** new **`ProfileView`** (`ProfileViewModel`) showing
+ avatar/bio/counts, a Follow/Unfollow button reflecting `.../status`, tabs for
+ the user's messages/lists/documents; a **Follow Requests** panel (approve/reject)
+ surfaced in the right rail or Alerts area. Wire `NavigateToProfile` from Phase 0
+ into feed cards and search results.
+- **Payoff:** turns the existing user-search stub into a real social experience;
+ unblocks "who follows me" and request management.
+
+### Phase 3 — Direct Messages (P0, whole new nav item)
+
+*Endpoints:* all of `/api/dm/*`.
+
+- **Services:** new `InterlinedApiClient.DirectMessages.cs`.
+- **ViewModels/Views:** new **`MessagesView`** (rename-safe: call it *Direct
+ Messages* to avoid clashing with Feed) — left column = conversation list
+ (`GET /api/dm`, unread badges from `GET /api/dm/unread-count`), right column =
+ thread (`GET /api/dm/thread/{username}` + composer + image attach). Poll
+ `.../updates` on a timer for near-real-time; mark read on open
+ (`POST /api/dm/{id}/read`); trash/restore in a context menu.
+- **Shell:** add a **DM** entry to the left nav in `MainWindow.xaml.cs`
+ `NavItem_Click` (per-tag `_views` cache pattern already there) and a global
+ unread badge on that nav item.
+- **Risks:** polling cadence vs. rate limits (`GET /api/limits`); this is the
+ largest single new surface — budget accordingly.
+
+### Phase 4 — Moderation + Account & Settings (P1)
+
+*Endpoints:* `/api/users/{username}/block|mute|report`, `GET /api/user/blocks|mutes`,
+`POST /api/messages/{id}/report` (from Phase 1); `PATCH /api/user/update`,
+`POST /api/user/avatar/*`, `POST /api/user/change-email/request`,
+`GET`/`PATCH /api/user/notification-preferences`, `POST /api/user/delete`,
+`GET /api/user/sessions`, `DELETE /api/user/sessions/{id}`.
+
+- **Services:** `InterlinedApiClient.Moderation.cs` + `InterlinedApiClient.Account.cs`.
+- **ViewModels/Views:** a new **Settings** view with tabs — *Profile* (edit +
+ avatar upload), *Notifications* (prefs), *Blocked & Muted* (lists with unblock/
+ unmute), *Sessions* (list active tokens, revoke — **pending §5 verification**),
+ *Account* (change email, delete). Add block/mute/report to profile and message
+ context menus (Phase 1/2 hooks).
+- **Security note:** if `DELETE /api/user/sessions/{id}` is real, update
+ `CLAUDE.md` and the `CredentialStore` docs — the standing-credential caveat
+ would no longer be strictly true.
+
+### Phase 5 — Lists & Documents depth (P1→P2)
+
+*Lists:* row edit/delete (`GET`/`PUT`/`DELETE /api/lists/{id}/data/{rowId}`),
+metadata/update (`GET`/`PUT /api/lists/{id}`), folders (`/api/folders`), watchers
+& share-links, connections, contributors, `GET /api/lists/watching`,
+`POST /api/lists/{id}/refresh`.
+*Documents:* folder CRUD + `GET /api/documents/tree`, share-links, collaborators,
+image upload, `seed-defaults`.
+
+- **Services:** extend `.Lists.cs` and `.Documents.cs` partials.
+- **ViewModels/Views:** add inline **row editing/deletion** to `ListsView`
+ (biggest immediate value — today rows are write-once); a folder tree for both
+ Lists and Documents; a **Share** dialog (create/revoke links, invite
+ watchers/collaborators by user search) shared between the two domains.
+- **Schema DSL:** still deferred — `PUT /api/lists/{id}/schema` is only partially
+ reverse-engineered (`CLAUDE.md`); keep freeform rows as the supported path
+ until the DSL is verified against a test account.
+
+### Phase 6 — Cross-cutting extras (P2→P3)
+
+- **Exports:** `GET /api/exports/*` → a simple "Export to CSV" menu (save-file
+ dialog) per domain. Cheap, high perceived value.
+- **Create from… / Materialize:** `POST /api/materialize` → a "Turn into
+ List/Document" action on feed cards and multi-select.
+- **Organizations manage:** `PUT`/`DELETE /api/organizations/{id}`, org users.
+ **Skip members mgmt** — cookie-session-only (401 with bearer). Note the block
+ in-UI rather than shipping a failing button.
+- **GitHub integration:** repos/issues panel (P3) — only if users ask; sizable
+ and orthogonal to the social core.
+- **Billing:** surface plan status + "Manage subscription" that opens the Stripe
+ portal **in the OS browser** (same handoff pattern as OAuth), since checkout/
+ portal creation is cookie-session-only. This at least makes subscriber-gated
+ features (media upload, some creates) explainable in-app.
+- **Auth self-service:** register / forgot-password as pre-login screens; logout;
+ multi-account switch. Lower priority because sync-token login already works.
+
+### Phase 7 — Deferred / research-grade (P3)
+
+Document delta-sync (`/api/documents/sync`), live presence/cursors, dashboard &
+front-wall layouts, widgets/weather. These are either large (offline-merge),
+low desktop value, or web-dashboard-specific. Track but don't schedule until the
+core social parity above is closed.
+
+---
+
+## 4. Suggested sequencing & milestones
+
+| Milestone | Phases | Outcome |
+|---|---|---|
+| **M1 — "A real feed"** | 0, 1 | Threads, edit/delete, report, media, scheduled posts |
+| **M2 — "Social"** | 2, 3 | Profiles, follow graph, Direct Messages |
+| **M3 — "Trust & self-service"** | 4 | Moderation + full Settings/Account |
+| **M4 — "Power features"** | 5 | Editable list rows, folders, sharing/collab |
+| **M5 — "Everything else"** | 6, (7) | Exports, materialize, GitHub, billing handoff |
+
+M1–M2 close the visible parity gap for a *social* product; M3 makes it
+trustworthy; M4–M5 reach functional parity minus the auth-model-blocked and
+web-dashboard-specific corners.
+
+---
+
+## 5. Cross-cutting engineering notes & verification checklist
+
+These apply to **every** phase and encode the constraints already learned in
+this codebase (`CLAUDE.md` "load-bearing constraints"):
+
+1. **Live-verify before typing write responses.** Keep the read-after-write
+ pattern for any mutating endpoint whose envelope hasn't been confirmed against
+ a real (test) account. Don't strictly deserialize an unverified body.
+2. **Re-probe the auth model per endpoint.** Bearer sync-token works for most,
+ but **not all** — org members (`/api/organizations/{id}/members*`) and
+ LinkedIn targets return 401 with bearer. Before building any `session`-scoped
+ endpoint (billing, some auth/account flows), confirm it accepts the bearer
+ token; if not, it's either browser-handoff (like OAuth) or genuinely blocked.
+3. **⚠️ Resolve the sessions/revoke contradiction (do this early).**
+ `CLAUDE.md` says the sync-token has no server-side revoke; the spec advertises
+ `GET /api/user/sessions` + `DELETE /api/user/sessions/{id}`. Probe both with
+ the `.env` test account. Whichever is true, **update `CLAUDE.md` and the
+ memory note** so the standing-credential guidance is correct.
+4. **Subscriber gating is real.** Many creates + media uploads require a
+ subscriber (`POST /api/lists`, `/api/folders`, doc creates, image/video
+ upload). Handle `402/403` as a first-class "upgrade needed" state, not a
+ generic error — this is why Phase 6 billing (plan visibility) matters.
+5. **Media = multipart, not JSON.** The current client only sends JSON. Image/
+ video/avatar uploads need the Phase 0 multipart helper.
+6. **Pagination everywhere.** Follow lists, DM threads, replies, blocks/mutes,
+ a user's messages all paginate — reuse the existing `Pagination`/`limit+offset`
+ convention and infinite-scroll pattern from `FeedViewModel`.
+7. **OAuth / browser handoffs stay external.** No WebView2 in this app — OAuth
+ linking (existing) and any Stripe portal/checkout (Phase 6) open the OS
+ default browser and rely on the user returning and refreshing. Don't attempt
+ inline web auth.
+8. **Design tokens only.** New views (Profile, DM, Settings, Share dialogs) must
+ use Strata tokens — teal structure, green actions, amber for live/Dig — sharp
+ 3–4px corners, the 4pt grid. No new colors/fonts/radii.
+9. **Avoid modal browser dialogs / native message boxes that block** the WPF
+ dispatcher during long polls (DM updates) — prefer non-blocking toasts and
+ confirm-in-place UI.