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
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
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;
}
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();
}
}
13 changes: 13 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Lists.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,17 @@ public Task UpdateListRowAsync(string listId, string rowId, Dictionary<string, o

public Task DeleteListRowAsync(string listId, string rowId, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/data/{rowId}", null, ct);

/// <summary>
/// Lists owned by others that have been shared with the current user
/// (GET /api/lists/watching → { lists, pagination }). Their rows are readable
/// via <see cref="GetListDataAsync"/> (access is granted server-side).
/// </summary>
public async Task<List<WatchedList>> GetWatchingListsAsync(CancellationToken ct = default)
{
var json = await GetElementAsync("api/lists/watching", ct);
return json.TryGetProperty("lists", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<WatchedList>>(JsonOptions) ?? new()
: new();
}
}
18 changes: 18 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Messages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,22 @@ public async Task<string> UploadMessageImageAsync(Stream content, string fileNam
? u
: throw new InterlinedApiException(200, "Image upload returned no url.");
}

/// <summary>Upload a video; same multipart shape as image upload (field "file" → { url }).</summary>
public async Task<string> UploadMessageVideoAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
{
var json = await SendMultipartAsync("api/messages/videos/upload", content, fileName, contentType, ct: ct);
return json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } u
? u
: throw new InterlinedApiException(200, "Video upload returned no url.");
}

/// <summary>GET /api/messages/scheduled → the current user's not-yet-published posts ({ messages }).</summary>
public async Task<List<Message>> GetScheduledMessagesAsync(CancellationToken ct = default)
{
var json = await GetElementAsync("api/messages/scheduled", ct);
return json.TryGetProperty("messages", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<Message>>(JsonOptions) ?? new()
: new();
}
}
8 changes: 5 additions & 3 deletions InterlinedList/Services/InterlinedApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ public async Task PostMessageAsync(
string? parentId = null,
DateTimeOffset? scheduledAt = null,
IReadOnlyList<string>? imageUrls = null,
IReadOnlyList<string>? videoUrls = null,
CancellationToken ct = default)
{
// parentId turns this into a reply; scheduledAt defers publication;
// imageUrls attaches already-uploaded images. All are documented request
// fields on POST /api/messages (OpenAPI-verified).
// image/videoUrls attach already-uploaded media. All are documented
// request fields on POST /api/messages (OpenAPI-verified).
using var resp = await SendAsync(HttpMethod.Post, "api/messages", new
{
content,
Expand All @@ -82,7 +83,8 @@ public async Task PostMessageAsync(
mastodonProviderIds,
parentId,
scheduledAt = scheduledAt?.UtcDateTime,
imageUrls
imageUrls,
videoUrls
}, ct);
await EnsureSuccessAsync(resp, ct);
}
Expand Down
146 changes: 144 additions & 2 deletions InterlinedList/ViewModels/DirectMessagesViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Models;
using InterlinedList.Services;
using Microsoft.Win32;

namespace InterlinedList.ViewModels;

public partial class DirectMessagesViewModel : ObservableObject
{
private readonly SessionService _session;

// Ids of every message currently shown in the thread — used to dedupe
// messages appended by the near-real-time poll.
private readonly HashSet<string> _shownMessageIds = new();

// Polls GetDmThreadUpdatesAsync while a recipient is selected. Fires on the
// UI thread, so its Tick handler can mutate the Messages collection directly.
private readonly DispatcherTimer _pollTimer;

public ObservableCollection<DmRecipient> Recipients { get; } = new();
public ObservableCollection<DmMessageViewModel> Messages { get; } = new();

// Images uploaded for the next DM (URLs returned by the upload endpoint).
public ObservableCollection<string> AttachedImageUrls { get; } = new();

[ObservableProperty]
private DmRecipient? selectedRecipient;

Expand All @@ -25,6 +39,9 @@ public partial class DirectMessagesViewModel : ObservableObject
[ObservableProperty]
private bool isSending;

[ObservableProperty]
private bool isUploadingImage;

[ObservableProperty]
private string? errorMessage;

Expand All @@ -33,6 +50,10 @@ public partial class DirectMessagesViewModel : ObservableObject
public DirectMessagesViewModel(SessionService session)
{
_session = session;
AttachedImageUrls.CollectionChanged += (_, _) => SendCommand.NotifyCanExecuteChanged();

_pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_pollTimer.Tick += async (_, _) => await PollThreadUpdatesAsync();
}

[RelayCommand]
Expand Down Expand Up @@ -62,8 +83,12 @@ private async Task LoadAsync()
[RelayCommand]
private async Task SelectRecipientAsync(DmRecipient recipient)
{
// Switching conversations: stop polling the old one until the new
// thread has loaded, then restart against the new recipient.
_pollTimer.Stop();
SelectedRecipient = recipient;
await LoadThreadAsync(recipient);
_pollTimer.Start();
}

private async Task LoadThreadAsync(DmRecipient recipient)
Expand All @@ -75,8 +100,12 @@ private async Task LoadThreadAsync(DmRecipient recipient)
var thread = await _session.Api.GetDmThreadAsync(recipient.Username);

Messages.Clear();
_shownMessageIds.Clear();
foreach (var message in thread.Items)
{
Messages.Add(new DmMessageViewModel(message, currentUserId));
_shownMessageIds.Add(message.Id);
}

ErrorMessage = null;
}
Expand All @@ -91,7 +120,8 @@ private async Task LoadThreadAsync(DmRecipient recipient)
}

private bool CanSend() =>
HasSelection && !IsSending && !string.IsNullOrWhiteSpace(ComposeText);
HasSelection && !IsSending &&
(!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0);

// SendDmAsync doesn't return a parsed message body (see
// InterlinedApiClient.DirectMessages.cs), so re-fetch the thread after sending.
Expand All @@ -104,8 +134,12 @@ private async Task SendAsync()
IsSending = true;
try
{
await _session.Api.SendDmAsync(recipient.Id, ComposeText.Trim());
await _session.Api.SendDmAsync(
recipient.Id,
ComposeText.Trim(),
imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null);
ComposeText = "";
AttachedImageUrls.Clear();
await LoadThreadAsync(recipient);
}
catch (InterlinedApiException ex)
Expand All @@ -118,10 +152,118 @@ private async Task SendAsync()
}
}

[RelayCommand]
private async Task AttachImageAsync()
{
var dlg = new OpenFileDialog
{
Filter = "Images (*.png;*.jpg;*.jpeg;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.gif;*.webp",
Multiselect = false
};
if (dlg.ShowDialog() != true) return;

IsUploadingImage = true;
try
{
var contentType = Path.GetExtension(dlg.FileName).ToLowerInvariant() switch
{
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg",
};
await using var stream = File.OpenRead(dlg.FileName);
var url = await _session.Api.UploadDmImageAsync(stream, Path.GetFileName(dlg.FileName), contentType);
AttachedImageUrls.Add(url);
ErrorMessage = null;
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
catch (IOException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsUploadingImage = false;
}
}

[RelayCommand]
private void RemoveAttachment(string url) => AttachedImageUrls.Remove(url);

// Trash/restore act on the current user's own messages, then re-fetch the
// thread (the write endpoints don't return a parsed body — read-after-write).
[RelayCommand]
private async Task TrashMessageAsync(DmMessageViewModel message)
{
try
{
await _session.Api.TrashDmAsync(message.Id);
if (SelectedRecipient is { } recipient)
await LoadThreadAsync(recipient);
ErrorMessage = null;
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
}

[RelayCommand]
private async Task RestoreMessageAsync(DmMessageViewModel message)
{
try
{
await _session.Api.RestoreDmAsync(message.Id);
if (SelectedRecipient is { } recipient)
await LoadThreadAsync(recipient);
ErrorMessage = null;
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
}

// Lightweight incremental fetch on the poll timer. Appends only messages
// whose Id isn't already shown; transient poll failures are swallowed so
// they don't spam the error banner.
private async Task PollThreadUpdatesAsync()
{
if (SelectedRecipient is not { } recipient)
return;

try
{
var currentUserId = _session.CurrentUser?.Id;
var updates = await _session.Api.GetDmThreadUpdatesAsync(recipient.Username);

// The recipient may have changed while the fetch was in flight.
if (SelectedRecipient?.Username != recipient.Username)
return;

foreach (var message in updates)
{
if (_shownMessageIds.Add(message.Id))
Messages.Add(new DmMessageViewModel(message, currentUserId));
}
}
catch (InterlinedApiException)
{
// Transient poll failure — ignore, next tick retries.
}
}

partial void OnSelectedRecipientChanged(DmRecipient? value)
{
OnPropertyChanged(nameof(HasSelection));
SendCommand.NotifyCanExecuteChanged();
// No selection → nothing to poll.
if (value is null)
_pollTimer.Stop();
}

partial void OnComposeTextChanged(string value) => SendCommand.NotifyCanExecuteChanged();
Expand Down
8 changes: 8 additions & 0 deletions InterlinedList/ViewModels/DmMessageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ public DmMessageViewModel(DirectMessage message, string? currentUserId)
IsMine = message.SenderId == currentUserId;
}

public string Id => _message.Id;
public string Body => _message.Body;
public string TimeFormatted => _message.TimeFormatted;
public bool IsMine { get; }

public IReadOnlyList<string> ImageUrls => _message.ImageUrls ?? (IReadOnlyList<string>)Array.Empty<string>();
public bool HasImages => _message.ImageUrls is { Count: > 0 };

// A message is "trashed" from this user's perspective when it's their own
// and they've soft-deleted it (SenderDeletedAt). The bubble dims/relabels.
public bool IsTrashed => IsMine && _message.SenderDeletedAt is not null;
}
Loading
Loading