Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 51 additions & 28 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
18 changes: 18 additions & 0 deletions InterlinedList/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,22 @@

<Button x:Name="NavFeed" Tag="Feed" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Feed"/>
<Button x:Name="NavMessages" Tag="Messages" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Messages"/>
<Button x:Name="NavLists" Tag="Lists" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Lists"/>
<Button x:Name="NavDocuments" Tag="Documents" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Documents"/>
<Button x:Name="NavOrganizations" Tag="Organizations" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Organizations"/>
<Button x:Name="NavPeople" Tag="People" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="People"/>
<Button x:Name="NavSearch" Tag="Search" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Search"/>
<Button x:Name="NavAccounts" Tag="Accounts" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Accounts"/>
<Button x:Name="NavSettings" Tag="Settings" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Settings"/>
<Button x:Name="NavAlerts" Tag="Alerts" Click="NavItem_Click"
Style="{StaticResource NavItemStyle}" Content="Alerts"/>

Expand Down Expand Up @@ -232,6 +238,18 @@
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<Button Content="Read"
Command="{Binding DataContext.Notifications.MarkOneReadCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Style="{StaticResource DigBtnStyle}"
Margin="0,0,6,0"
Visibility="{Binding IsUnread, Converter={StaticResource BoolToVisibility}}"/>
<Button Content="Delete"
Command="{Binding DataContext.Notifications.DeleteOneCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Style="{StaticResource DigBtnStyle}"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
Expand Down
16 changes: 16 additions & 0 deletions InterlinedList/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -60,18 +63,31 @@ 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;

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(),
};

Expand Down
19 changes: 19 additions & 0 deletions InterlinedList/Models/ApiSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace InterlinedList.Models;

/// <summary>
/// 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).
/// </summary>
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") ?? "—";
}
21 changes: 21 additions & 0 deletions InterlinedList/Models/DirectMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace InterlinedList.Models;

/// <summary>
/// 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.
/// </summary>
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<string>? 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'");
}
16 changes: 16 additions & 0 deletions InterlinedList/Models/DmRecipient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace InterlinedList.Models;

/// <summary>
/// A person the current user can DM (GET /api/dm/recipients) and the
/// "otherUser" identity embedded in a thread payload.
/// </summary>
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}";
}
14 changes: 14 additions & 0 deletions InterlinedList/Models/DmThread.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace InterlinedList.Models;

/// <summary>
/// GET /api/dm/thread/{username} — the conversation with one person, oldest
/// page first. OlderCursor paginates backward into history.
/// </summary>
public sealed class DmThread
{
public required List<DirectMessage> Items { get; init; }
public DmRecipient? OtherUser { get; init; }
public bool IsBlocked { get; init; }
public bool IsMutual { get; init; }
public string? OlderCursor { get; init; }
}
13 changes: 13 additions & 0 deletions InterlinedList/Models/FollowStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace InterlinedList.Models;

/// <summary>
/// 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.
/// </summary>
public sealed class FollowStatus
{
public string? Status { get; init; }
public bool IsFollowing { get; init; }
public bool IsPending { get; init; }
}
20 changes: 20 additions & 0 deletions InterlinedList/Models/FollowUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace InterlinedList.Models;

/// <summary>
/// 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).
/// </summary>
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}";
}
17 changes: 17 additions & 0 deletions InterlinedList/Models/ModeratedUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace InterlinedList.Models;

/// <summary>
/// A user entry in the current user's block list (GET /api/user/blocks →
/// blockedUsers[]) or mute list (GET /api/user/mutes → mutedUsers[]).
/// </summary>
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}";
}
20 changes: 20 additions & 0 deletions InterlinedList/Models/NotificationPreference.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace InterlinedList.Models;

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

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

/// <summary>
/// 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).
/// </summary>
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}";
}
Loading
Loading