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
58 changes: 58 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Release

# Cut a versioned GitHub Release with the installable Windows MSI.
# Trigger by pushing a semver tag, e.g. git tag v1.0.0 && git push origin v1.0.0
# (or run manually from the Actions tab via workflow_dispatch on a tag).
on:
push:
tags:
- 'v*'
workflow_dispatch:

permissions:
contents: write # required to create the release + upload assets

jobs:
release:
name: Build MSI and publish release
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

# Same two-step MSI build as the CI Build workflow: publish the app, then
# let WiX harvest the publish output into the installer.
- name: Publish app (installer payload)
run: dotnet publish InterlinedList/InterlinedList.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=false

- name: Build MSI
run: dotnet build installer/InterlinedList.Installer.wixproj -c Release

- name: Collect MSI
shell: pwsh
run: |
New-Item -ItemType Directory -Force dist | Out-Null
$msi = Get-ChildItem -Recurse installer/bin -Filter *.msi | Select-Object -First 1
if (-not $msi) { throw "No .msi produced by the WiX build." }
Copy-Item $msi.FullName "dist/InterlinedList-Setup.msi"
Get-ChildItem dist

- name: Upload MSI as workflow artifact
uses: actions/upload-artifact@v4
with:
name: InterlinedList-Setup-msi
path: dist/InterlinedList-Setup.msi
if-no-files-found: error

# On a tag push this creates (or updates) the GitHub Release for that tag
# and attaches the MSI as a downloadable asset.
- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: dist/InterlinedList-Setup.msi
generate_release_notes: true
fail_on_unmatched_files: true
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ three real, non-obvious issues surfaced and are fixed in the current state
installed on the runner (see above) β€” it drifts as GitHub updates runner
images, so a future image update could reintroduce this failure.

**Releases** β€” `.github/workflows/release.yml` triggers on a pushed `v*` tag
(e.g. `git tag v1.0.0 && git push origin v1.0.0`). It runs the same
publish β†’ WiX MSI build as CI, then attaches `InterlinedList-Setup.msi` to a
GitHub Release for that tag (auto-generated notes). Keep the tag version in
sync with `installer/Package.wxs` `Version` and `Package.appxmanifest`
`Version` (both `1.0.0.0` today) β€” bump all three together for a new release.

## Windows-specific rules

- App icon: `brand-kit/icons/windows/InterlinedList.ico` (set via ApplicationIcon in .csproj)
Expand Down
55 changes: 55 additions & 0 deletions InterlinedList/LoginWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@
BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>

<!-- Registration-only fields (toggled from code-behind) -->
<StackPanel x:Name="RegisterFields" Visibility="Collapsed">
<TextBlock Text="USERNAME"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6" Margin="0,0,0,14"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>
<TextBlock Text="DISPLAY NAME (OPTIONAL)"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,4"/>
<TextBox Text="{Binding DisplayName, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6" Margin="0,0,0,14"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Foreground="{DynamicResource TextBrush}"/>
</StackPanel>

<TextBlock Text="PASSWORD"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource TextMutedBrush}"
Expand Down Expand Up @@ -139,6 +159,18 @@
HorizontalAlignment="Stretch"
Margin="0,10,0,0"/>

<Button x:Name="BtnForgot"
Content="Forgot password?"
Click="BtnForgot_Click"
Style="{StaticResource LinkBtnStyle}"
Margin="0,14,0,0"/>

<Button x:Name="BtnToggleMode"
Content="{Binding ToggleModeText}"
Click="BtnToggleMode_Click"
Style="{StaticResource LinkBtnStyle}"
Margin="0,10,0,0"/>

</StackPanel>
</Border>
</Grid>
Expand Down Expand Up @@ -215,6 +247,29 @@
</Setter>
</Style>

<!-- Text link (forgot password / toggle register) -->
<Style x:Key="LinkBtnStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource LinkBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter HorizontalAlignment="Center"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="TextBlock.TextDecorations" Value="Underline"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

</Window.Resources>

</Window>
18 changes: 16 additions & 2 deletions InterlinedList/LoginWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,23 @@ private async void PasswordInput_KeyDown(object sender, KeyEventArgs e)

private async Task SubmitAsync()
{
var succeeded = await _viewModel.LoginAsync(PasswordInput.Password);
var succeeded = _viewModel.IsRegisterMode
? await _viewModel.RegisterAsync(PasswordInput.Password)
: await _viewModel.LoginAsync(PasswordInput.Password);
if (succeeded)
LoginSucceeded?.Invoke(this, EventArgs.Empty);
}

private async void BtnForgot_Click(object sender, RoutedEventArgs e)
=> await _viewModel.ForgotPasswordAsync();

private void BtnToggleMode_Click(object sender, RoutedEventArgs e)
{
_viewModel.IsRegisterMode = !_viewModel.IsRegisterMode;
RegisterFields.Visibility = _viewModel.IsRegisterMode ? Visibility.Visible : Visibility.Collapsed;
BtnLogin.Content = _viewModel.PrimaryButtonText;
}

private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
Expand All @@ -54,7 +66,9 @@ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs

case nameof(LoginViewModel.IsBusy):
BtnLogin.IsEnabled = !_viewModel.IsBusy;
BtnLogin.Content = _viewModel.IsBusy ? "Logging in…" : "Log In";
BtnLogin.Content = _viewModel.IsBusy
? "Working…"
: _viewModel.PrimaryButtonText;
break;
}
}
Expand Down
2 changes: 2 additions & 0 deletions InterlinedList/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public MainWindow()

// Feed/search cards raise this to open a user's profile in the People tab.
Navigator.OnOpenProfile = OpenProfile;
// Account deletion (Settings) routes back to the login screen through here.
Navigator.OnLoggedOut = () => LoggedOut?.Invoke(this, EventArgs.Empty);

StartClock();

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

/// <summary>
/// A person granted shared access to a list (a "watcher") or a document (a
/// "collaborator"). Same wire shape for both (verified live 2026-08-01):
/// { id, userId, role, createdAt, user }. Remove using <see cref="UserId"/>
/// (the DELETE routes are keyed by user id, not the edge id).
/// </summary>
public sealed class Collaborator
{
public required string Id { get; init; }
public required string UserId { get; init; }
public string? Role { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public ApiUser? User { get; init; }

public string DisplayNameOrUsername => User?.DisplayName ?? User?.Username ?? "unknown";
public string Handle => User is null ? string.Empty : $"@{User.Username}";
public string RoleLabel => string.IsNullOrEmpty(Role) ? "watcher" : Role;
}
1 change: 1 addition & 0 deletions InterlinedList/Models/Message.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed class Message
public bool DugByMe { get; init; }
public ApiUser? User { get; init; }
public List<string>? ImageUrls { get; init; }
public List<string>? VideoUrls { get; init; }
public List<string>? Tags { get; init; }

public string TimeFormatted => CreatedAt.ToUniversalTime().ToString("HH:mm:ss'Z'");
Expand Down
19 changes: 19 additions & 0 deletions InterlinedList/Models/ShareLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace InterlinedList.Models;

/// <summary>
/// A tokenized public share link for a list or document. Shape verified live
/// 2026-08-01: POST returns { token, url, role, expiresAt }; GET adds
/// createdAt / revokedAt. Revoke with DELETE …/share-links/{token}.
/// </summary>
public sealed class ShareLink
{
public required string Token { get; init; }
public string? Url { get; init; }
public string? Role { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public DateTimeOffset? RevokedAt { get; init; }

public bool IsActive => RevokedAt is null;
public string RoleLabel => string.IsNullOrEmpty(Role) ? "viewer" : Role;
}
20 changes: 20 additions & 0 deletions InterlinedList/Models/WatchedList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace InterlinedList.Models;

/// <summary>
/// A list owned by someone else that the current user has been granted access to
/// (GET /api/lists/watching). Role is "collaborator" / "viewer" / etc. The
/// current user can read its rows via the normal list-data endpoint (verified
/// live 2026-07-31).
/// </summary>
public sealed class WatchedList
{
public required string Id { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public bool IsPublic { get; init; }
public string? Role { get; init; }
public ApiUser? User { get; init; }

public string OwnerHandle => User is null ? string.Empty : $"@{User.Username}";
public string RoleLabel => string.IsNullOrEmpty(Role) ? "viewer" : Role;
}
20 changes: 20 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Auth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Net.Http;

namespace InterlinedList.Services;

/// <summary>
/// Pre-login self-service: account registration and password reset. Both are
/// public (no bearer). Request shapes verified against the OpenAPI spec
/// 2026-08-01: register { email, username, password, displayName }, forgot
/// { email }. Responses aren't parsed β€” on success the caller either logs in
/// with the new credentials or tells the user to check their inbox.
/// </summary>
public sealed partial class InterlinedApiClient
{
public Task RegisterAsync(string email, string username, string password, string? displayName, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/auth/register",
new { email, username, password, displayName }, ct);

public Task ForgotPasswordAsync(string email, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/auth/forgot-password", new { email }, ct);
}
26 changes: 24 additions & 2 deletions InterlinedList/Services/InterlinedApiClient.DirectMessages.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Net.Http;
using System.Text.Json;
using InterlinedList.Models;
Expand Down Expand Up @@ -31,12 +32,33 @@ public async Task<int> GetDmUnreadCountAsync(CancellationToken ct = default)
return json.TryGetProperty("count", out var c) && c.TryGetInt32(out var n) ? n : 0;
}

public Task SendDmAsync(string recipientId, string body, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/dm", new { recipientId, body }, ct);
public Task SendDmAsync(string recipientId, string body, IReadOnlyList<string>? imageUrls = null, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, "api/dm", new { recipientId, body, imageUrls }, ct);

public Task MarkDmReadAsync(string id, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/dm/{id}/read", new { }, ct);

public Task TrashDmAsync(string id, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/dm/{id}/trash", new { }, ct);

public Task RestoreDmAsync(string id, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/dm/{id}/restore", new { }, ct);

/// <summary>Upload an image attachment for a DM (multipart field "file" β†’ { url }, verified live).</summary>
public async Task<string> UploadDmImageAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
{
var json = await SendMultipartAsync("api/dm/images/upload", content, fileName, contentType, ct: ct);
return json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } u
? u
: throw new InterlinedApiException(200, "DM image upload returned no url.");
}

/// <summary>Lightweight incremental fetch for polling an open thread ({ items }).</summary>
public async Task<List<DirectMessage>> GetDmThreadUpdatesAsync(string username, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/dm/thread/{Uri.EscapeDataString(username)}/updates", ct);
return json.TryGetProperty("items", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<DirectMessage>>(JsonOptions) ?? new()
: new();
}
}
42 changes: 42 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Documents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,46 @@ public Task DeleteDocumentFolderAsync(string id, CancellationToken ct = default)
public Task CreateDocumentInFolderAsync(string folderId, string title, string content, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/documents/folders/{folderId}/documents",
new { title, content, isPublic = false }, ct);

// ── Share links (create a public read link for a document) ──────────────────
// Same shape as list share-links (verified live 2026-08-01).

public async Task<List<ShareLink>> GetDocumentShareLinksAsync(string documentId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/documents/{documentId}/share-links", ct);
return json.TryGetProperty("shareLinks", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<ShareLink>>(JsonOptions) ?? new()
: new();
}

public Task<ShareLink> CreateDocumentShareLinkAsync(string documentId, CancellationToken ct = default)
=> SendJsonAsync<ShareLink>(HttpMethod.Post, $"api/documents/{documentId}/share-links", new { }, ct);

public Task DeleteDocumentShareLinkAsync(string documentId, string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/documents/{documentId}/share-links/{token}", null, ct);

// ── Collaborators (per-user shared access to a document) ────────────────────
// Same shape as list watchers (verified live 2026-08-01).

public async Task<List<Collaborator>> GetDocumentCollaboratorsAsync(string documentId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/documents/{documentId}/collaborators", ct);
return json.TryGetProperty("collaborators", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<Collaborator>>(JsonOptions) ?? new()
: new();
}

public Task AddDocumentCollaboratorAsync(string documentId, string userId, string role = "watcher", CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Post, $"api/documents/{documentId}/collaborators", new { userId, role }, ct);

public Task RemoveDocumentCollaboratorAsync(string documentId, string userId, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/documents/{documentId}/collaborators/{userId}", null, ct);

public async Task<List<UserSearchResult>> SearchCollaboratorUsersAsync(string documentId, string query, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/documents/{documentId}/collaborators/users?q={Uri.EscapeDataString(query)}", ct);
return json.TryGetProperty("users", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<UserSearchResult>>(JsonOptions) ?? new()
: new();
}
}
Loading
Loading