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

/// <summary>
/// Request shape for <c>POST /api/messages</c>. A record rather than a long
/// positional parameter list because the endpoint keeps growing optional fields
/// (reply, schedule, media, push/quote, tags) and every caller only ever sets
/// two or three of them.
/// </summary>
/// <remarks>
/// The client serializes this by hand (see
/// <c>InterlinedApiClient.PostMessageAsync(NewMessage, CancellationToken)</c>)
/// and omits the optional keys it hasn't been given, rather than posting
/// explicit nulls for fields the caller never touched.
/// </remarks>
public sealed record NewMessage
{
public string Content { get; init; } = "";

/// <summary>
/// Normally the user's <c>defaultPubliclyVisible</c> preference — but a Push
/// or Quote is <b>always</b> public per <c>/help/messages</c>, so those paths
/// hard-code <c>true</c>.
/// </summary>
public bool PubliclyVisible { get; init; } = true;

public bool CrossPostToBluesky { get; init; }
public bool CrossPostToTwitter { get; init; }
public bool CrossPostToLinkedIn { get; init; }

/// <summary>Which Mastodon identity to syndicate through, if any.</summary>
public string? MastodonProviderIds { get; init; }

/// <summary>Set to turn this into a reply.</summary>
public string? ParentId { get; init; }

/// <summary>
/// Set to turn this into a Push (repost) when <see cref="Content"/> is empty,
/// or a Quote when it isn't — the API has one field for both.
/// </summary>
public string? PushedMessageId { get; init; }

/// <summary>Defer publication; the post lands in <c>/api/messages/scheduled</c>.</summary>
public DateTimeOffset? ScheduledAt { get; init; }

public IReadOnlyList<string>? ImageUrls { get; init; }
public IReadOnlyList<string>? VideoUrls { get; init; }
}
44 changes: 44 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Messages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,50 @@ public async Task<List<Message>> GetRepliesAsync(string id, CancellationToken ct
public Task PostReplyAsync(string parentId, string content, bool publiclyVisible, CancellationToken ct = default)
=> PostMessageAsync(content, publiclyVisible, parentId: parentId, ct: ct);

// ── Push (repost) / Quote ───────────────────────────────────────────────────

/// <summary>
/// <c>POST /api/messages</c> from a <see cref="NewMessage"/>. An overload of the
/// long positional <c>PostMessageAsync</c> in InterlinedApiClient.cs that can
/// also send <c>pushedMessageId</c>. Optional keys the caller didn't set are
/// omitted rather than sent as explicit nulls.
/// </summary>
public Task PostMessageAsync(NewMessage draft, CancellationToken ct = default)
{
var body = new Dictionary<string, object?>
{
["content"] = draft.Content,
["publiclyVisible"] = draft.PubliclyVisible,
["crossPostToBluesky"] = draft.CrossPostToBluesky,
["crossPostToTwitter"] = draft.CrossPostToTwitter,
["crossPostToLinkedIn"] = draft.CrossPostToLinkedIn,
};
if (draft.MastodonProviderIds is { Length: > 0 } mastodon) body["mastodonProviderIds"] = mastodon;
if (draft.ParentId is { Length: > 0 } parent) body["parentId"] = parent;
if (draft.PushedMessageId is { Length: > 0 } pushed) body["pushedMessageId"] = pushed;
if (draft.ScheduledAt is { } at) body["scheduledAt"] = at.UtcDateTime;
if (draft.ImageUrls is { Count: > 0 } images) body["imageUrls"] = images;
if (draft.VideoUrls is { Count: > 0 } videos) body["videoUrls"] = videos;

return SendVoidAsync(HttpMethod.Post, "api/messages", body, ct);
}

/// <summary>
/// Push (repost) a message as-is, with no commentary. Always public — pushes
/// and quotes are public by product rule (<c>/help/messages</c>), regardless of
/// the user's <c>defaultPubliclyVisible</c> preference.
/// </summary>
public Task PushMessageAsync(string messageId, CancellationToken ct = default)
=> PostMessageAsync(new NewMessage { Content = "", PubliclyVisible = true, PushedMessageId = messageId }, ct);

/// <summary>
/// Quote a message — a push carrying your own note. Same endpoint and same
/// <c>pushedMessageId</c> field as <see cref="PushMessageAsync"/>; non-empty
/// content is the only thing that distinguishes the two. Always public.
/// </summary>
public Task QuoteMessageAsync(string messageId, string content, CancellationToken ct = default)
=> PostMessageAsync(new NewMessage { Content = content, PubliclyVisible = true, PushedMessageId = messageId }, ct);

public Task EditMessageAsync(string id, string content, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Patch, $"api/messages/{id}", new { content }, ct);

Expand Down
20 changes: 17 additions & 3 deletions InterlinedList/ViewModels/FeedViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ public FeedViewModel(SessionService session)
AttachedVideoUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged();
}

/// <summary>
/// Wrap a wire message for the feed. Centralized so every card is hooked to
/// <see cref="MessageItemViewModel.Posted"/> — a Push or Quote publishes a new
/// post, and the write response isn't parsed, so the feed re-fetches.
/// </summary>
private MessageItemViewModel Wrap(Models.Message message)
{
var item = new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id);
item.Posted += OnItemPosted;
return item;
}

private void OnItemPosted(object? sender, EventArgs e) => RefreshCommand.Execute(null);

[RelayCommand]
private async Task AttachImageAsync()
{
Expand Down Expand Up @@ -189,7 +203,7 @@ private async Task LoadScheduledAsync()
var scheduled = await _session.Api.GetScheduledMessagesAsync();
ScheduledMessages.Clear();
foreach (var m in scheduled)
ScheduledMessages.Add(new MessageItemViewModel(m, _session.Api, _session.CurrentUser?.Id));
ScheduledMessages.Add(Wrap(m));
ErrorMessage = null;
}
catch (InterlinedApiException ex)
Expand Down Expand Up @@ -225,7 +239,7 @@ private async Task RefreshAsync()

Messages.Clear();
foreach (var message in page.Messages)
Messages.Add(new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id));
Messages.Add(Wrap(message));

HasMore = page.Pagination.HasMore;
_offset = page.Messages.Count;
Expand All @@ -252,7 +266,7 @@ private async Task LoadMoreAsync()
var page = await _session.Api.GetMessagesAsync(limit: PageSize, offset: _offset);

foreach (var message in page.Messages)
Messages.Add(new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id));
Messages.Add(Wrap(message));

_offset += page.Messages.Count;
HasMore = page.Pagination.HasMore;
Expand Down
155 changes: 155 additions & 0 deletions InterlinedList/ViewModels/MessageItemViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,60 @@ public partial class MessageItemViewModel : ObservableObject

public ObservableCollection<MessageItemViewModel> Replies { get; } = new();

// ── Push / Quote ────────────────────────────────────────────────────────────

/// <summary>This card is itself a Push or a Quote of another message.</summary>
public bool IsPushOrQuote { get; }

/// <summary>A bare repost: pushed, with no commentary of its own.</summary>
public bool IsPush { get; }

/// <summary>A push that added a note. The API has one field for both.</summary>
public bool IsQuote { get; }

/// <summary>Badge above the header row; null on an ordinary post.</summary>
public string? RepostLabel => IsQuote ? "❝ Quoted" : IsPush ? "↻ Pushed" : null;

/// <summary>A bare push has no body text of its own — collapse the body block.</summary>
public bool HasContent => !string.IsNullOrWhiteSpace(Content);

/// <summary>
/// What a Push/Quote from this card re-shares. Pushing a <i>bare push</i>
/// targets the original instead of the push, so the result is a one-level
/// quote we can actually render — no chain of empty cards. Quoting a Quote
/// still targets the quote, because a quote carries its own commentary.
/// Client-side choice: no nested <c>pushedMessage</c> appeared in the sampled
/// feed, so the server's own chaining behaviour is unverified.
/// </summary>
public string PushTargetId { get; }

public bool HasQuotedOriginal { get; }
public string? QuotedAuthorDisplayName { get; }
public string? QuotedAuthorHandle { get; }
public string? QuotedAuthorUsername { get; }
public string? QuotedAvatarUrl { get; }
public string? QuotedContent { get; }
public string? QuotedTimeFormatted { get; }
public IReadOnlyList<string> QuotedImageUrls { get; }
public bool QuotedHasImages => QuotedImageUrls.Count > 0;

/// <summary>
/// Raised after this card publishes something (a Push or a Quote) so the feed
/// that owns it can re-fetch — the write endpoints' response envelopes aren't
/// parsed, per the codebase's read-after-write discipline.
/// </summary>
public event EventHandler? Posted;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasContent))]
private string content;

[ObservableProperty]
private int digCount;

[ObservableProperty]
private int pushCount;

[ObservableProperty]
private bool dugByMe;

Expand All @@ -67,6 +115,12 @@ public partial class MessageItemViewModel : ObservableObject
[ObservableProperty]
private string replyText = "";

[ObservableProperty]
private bool isComposingQuote;

[ObservableProperty]
private string quoteText = "";

[ObservableProperty]
private string? errorMessage;

Expand All @@ -88,7 +142,23 @@ public MessageItemViewModel(Message message, InterlinedApiClient api, string? cu
VideoUrls = message.VideoUrls ?? new List<string>();

digCount = message.DigCount;
pushCount = message.PushCount;
dugByMe = message.DugByMe;

IsPushOrQuote = message.IsPushOrQuote;
IsQuote = message.IsQuote;
IsPush = message.IsPushOrQuote && !message.IsQuote;
PushTargetId = IsPush ? message.PushedMessageId! : message.Id;

var original = message.PushedMessage;
HasQuotedOriginal = original is not null;
QuotedAuthorDisplayName = original?.AuthorDisplayName;
QuotedAuthorHandle = original?.AuthorHandle;
QuotedAuthorUsername = original?.User?.Username;
QuotedAvatarUrl = original?.User?.Avatar;
QuotedContent = original?.Content;
QuotedTimeFormatted = original?.TimeFormatted;
QuotedImageUrls = original?.ImageUrls ?? new List<string>();
}

[RelayCommand]
Expand Down Expand Up @@ -133,6 +203,84 @@ private async Task DigAsync()
}
}

// ── Push (repost) / Quote ───────────────────────────────────────────────────

private bool CanPush() => !IsBusy;

/// <summary>
/// Push (repost) as-is — no comment, always public. Posts directly, per the
/// product behaviour in <c>/help/messages</c>; Quote is the path that asks
/// for confirmation first (it has a composer to show the banner in).
/// </summary>
[RelayCommand(CanExecute = nameof(CanPush))]
private async Task PushAsync()
{
IsBusy = true;
PushCount++;
try
{
await _api.PushMessageAsync(PushTargetId);
ErrorMessage = null;
Posted?.Invoke(this, EventArgs.Empty);
}
catch (InterlinedApiException ex)
{
PushCount--;
ErrorMessage = ex.Message;
}
finally
{
IsBusy = false;
}
}

[RelayCommand]
private void StartQuote() => IsComposingQuote = true;

[RelayCommand]
private void CancelQuote()
{
IsComposingQuote = false;
QuoteText = "";
}

private bool CanPostQuote() => !IsBusy && !string.IsNullOrWhiteSpace(QuoteText);

/// <summary>
/// Quote = push + commentary. Forced public: the composer shows the "always
/// public" banner while this is open, which is the confirmation the product
/// calls for.
/// </summary>
[RelayCommand(CanExecute = nameof(CanPostQuote))]
private async Task PostQuoteAsync()
{
IsBusy = true;
try
{
await _api.QuoteMessageAsync(PushTargetId, QuoteText.Trim());
QuoteText = "";
IsComposingQuote = false;
PushCount++;
ErrorMessage = null;
Posted?.Invoke(this, EventArgs.Empty);
}
catch (InterlinedApiException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsBusy = false;
}
}

[RelayCommand]
private void OpenQuotedAuthor()
{
if (!string.IsNullOrEmpty(QuotedAuthorUsername))
Navigator.OpenProfile(QuotedAuthorUsername);
}

// ── Edit (own message) ──────────────────────────────────────────────────────

[RelayCommand]
Expand Down Expand Up @@ -269,4 +417,11 @@ private void OpenVideo(string url)

partial void OnEditTextChanged(string value) => SaveEditCommand.NotifyCanExecuteChanged();
partial void OnReplyTextChanged(string value) => PostReplyCommand.NotifyCanExecuteChanged();
partial void OnQuoteTextChanged(string value) => PostQuoteCommand.NotifyCanExecuteChanged();

partial void OnIsBusyChanged(bool value)
{
PushCommand.NotifyCanExecuteChanged();
PostQuoteCommand.NotifyCanExecuteChanged();
}
}
Loading
Loading