diff --git a/InterlinedList/Models/Message.cs b/InterlinedList/Models/Message.cs index dcf901b..e335e55 100644 --- a/InterlinedList/Models/Message.cs +++ b/InterlinedList/Models/Message.cs @@ -14,6 +14,7 @@ public sealed class Message public bool DugByMe { get; init; } public ApiUser? User { get; init; } public List? ImageUrls { get; init; } + public List? VideoUrls { get; init; } public List? Tags { get; init; } public string TimeFormatted => CreatedAt.ToUniversalTime().ToString("HH:mm:ss'Z'"); diff --git a/InterlinedList/Models/WatchedList.cs b/InterlinedList/Models/WatchedList.cs new file mode 100644 index 0000000..a63125c --- /dev/null +++ b/InterlinedList/Models/WatchedList.cs @@ -0,0 +1,20 @@ +namespace InterlinedList.Models; + +/// +/// 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). +/// +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; +} diff --git a/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs b/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs index 623c5a1..5ed9bfb 100644 --- a/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs +++ b/InterlinedList/Services/InterlinedApiClient.DirectMessages.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Net.Http; using System.Text.Json; using InterlinedList.Models; @@ -31,12 +32,33 @@ public async Task 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? 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); + + /// Upload an image attachment for a DM (multipart field "file" → { url }, verified live). + public async Task 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."); + } + + /// Lightweight incremental fetch for polling an open thread ({ items }). + public async Task> 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>(JsonOptions) ?? new() + : new(); + } } diff --git a/InterlinedList/Services/InterlinedApiClient.Lists.cs b/InterlinedList/Services/InterlinedApiClient.Lists.cs index 89b9522..05fd413 100644 --- a/InterlinedList/Services/InterlinedApiClient.Lists.cs +++ b/InterlinedList/Services/InterlinedApiClient.Lists.cs @@ -70,4 +70,17 @@ public Task UpdateListRowAsync(string listId, string rowId, Dictionary SendVoidAsync(HttpMethod.Delete, $"api/lists/{listId}/data/{rowId}", null, ct); + + /// + /// Lists owned by others that have been shared with the current user + /// (GET /api/lists/watching → { lists, pagination }). Their rows are readable + /// via (access is granted server-side). + /// + public async Task> 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>(JsonOptions) ?? new() + : new(); + } } diff --git a/InterlinedList/Services/InterlinedApiClient.Messages.cs b/InterlinedList/Services/InterlinedApiClient.Messages.cs index c0a3635..2ff868c 100644 --- a/InterlinedList/Services/InterlinedApiClient.Messages.cs +++ b/InterlinedList/Services/InterlinedApiClient.Messages.cs @@ -57,4 +57,22 @@ public async Task UploadMessageImageAsync(Stream content, string fileNam ? u : throw new InterlinedApiException(200, "Image upload returned no url."); } + + /// Upload a video; same multipart shape as image upload (field "file" → { url }). + public async Task 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."); + } + + /// GET /api/messages/scheduled → the current user's not-yet-published posts ({ messages }). + public async Task> 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>(JsonOptions) ?? new() + : new(); + } } diff --git a/InterlinedList/Services/InterlinedApiClient.cs b/InterlinedList/Services/InterlinedApiClient.cs index c5ce42f..7d6bd30 100644 --- a/InterlinedList/Services/InterlinedApiClient.cs +++ b/InterlinedList/Services/InterlinedApiClient.cs @@ -67,11 +67,12 @@ public async Task PostMessageAsync( string? parentId = null, DateTimeOffset? scheduledAt = null, IReadOnlyList? imageUrls = null, + IReadOnlyList? 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, @@ -82,7 +83,8 @@ public async Task PostMessageAsync( mastodonProviderIds, parentId, scheduledAt = scheduledAt?.UtcDateTime, - imageUrls + imageUrls, + videoUrls }, ct); await EnsureSuccessAsync(resp, ct); } diff --git a/InterlinedList/ViewModels/DirectMessagesViewModel.cs b/InterlinedList/ViewModels/DirectMessagesViewModel.cs index 9f07584..2b2ed7f 100644 --- a/InterlinedList/ViewModels/DirectMessagesViewModel.cs +++ b/InterlinedList/ViewModels/DirectMessagesViewModel.cs @@ -1,8 +1,11 @@ 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; @@ -10,9 +13,20 @@ 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 _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 Recipients { get; } = new(); public ObservableCollection Messages { get; } = new(); + // Images uploaded for the next DM (URLs returned by the upload endpoint). + public ObservableCollection AttachedImageUrls { get; } = new(); + [ObservableProperty] private DmRecipient? selectedRecipient; @@ -25,6 +39,9 @@ public partial class DirectMessagesViewModel : ObservableObject [ObservableProperty] private bool isSending; + [ObservableProperty] + private bool isUploadingImage; + [ObservableProperty] private string? errorMessage; @@ -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] @@ -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) @@ -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; } @@ -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. @@ -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) @@ -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(); diff --git a/InterlinedList/ViewModels/DmMessageViewModel.cs b/InterlinedList/ViewModels/DmMessageViewModel.cs index 0356ea6..a3841e5 100644 --- a/InterlinedList/ViewModels/DmMessageViewModel.cs +++ b/InterlinedList/ViewModels/DmMessageViewModel.cs @@ -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 ImageUrls => _message.ImageUrls ?? (IReadOnlyList)Array.Empty(); + 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; } diff --git a/InterlinedList/ViewModels/FeedViewModel.cs b/InterlinedList/ViewModels/FeedViewModel.cs index 0e163a9..ad69d36 100644 --- a/InterlinedList/ViewModels/FeedViewModel.cs +++ b/InterlinedList/ViewModels/FeedViewModel.cs @@ -54,16 +54,40 @@ public partial class FeedViewModel : ObservableObject [ObservableProperty] private bool crossPostToMastodon; - // Images uploaded for the next post (URLs returned by the upload endpoint). + // Media uploaded for the next post (URLs returned by the upload endpoints). public ObservableCollection AttachedImageUrls { get; } = new(); + public ObservableCollection AttachedVideoUrls { get; } = new(); [ObservableProperty] private bool isUploadingImage; + [ObservableProperty] + private bool isUploadingVideo; + + // Scheduling: when IsScheduling, ScheduleDate + ScheduleTime ("HH:mm") set scheduledAt. + [ObservableProperty] + private bool isScheduling; + + [ObservableProperty] + private DateTime scheduleDate = DateTime.Today; + + [ObservableProperty] + private string scheduleTime = "09:00"; + + // "Scheduled posts" panel (loaded on demand, toggled from the header). + public ObservableCollection ScheduledMessages { get; } = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ScheduledToggleLabel))] + private bool showScheduled; + + public string ScheduledToggleLabel => ShowScheduled ? "← Back to feed" : "Scheduled"; + public FeedViewModel(SessionService session) { _session = session; AttachedImageUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged(); + AttachedVideoUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged(); } [RelayCommand] @@ -108,6 +132,72 @@ private async Task AttachImageAsync() [RelayCommand] private void RemoveAttachment(string url) => AttachedImageUrls.Remove(url); + [RelayCommand] + private async Task AttachVideoAsync() + { + var dlg = new OpenFileDialog + { + Filter = "Videos (*.mp4;*.mov;*.webm;*.m4v)|*.mp4;*.mov;*.webm;*.m4v", + Multiselect = false + }; + if (dlg.ShowDialog() != true) return; + + IsUploadingVideo = true; + try + { + var contentType = Path.GetExtension(dlg.FileName).ToLowerInvariant() switch + { + ".mov" => "video/quicktime", + ".webm" => "video/webm", + ".m4v" => "video/x-m4v", + _ => "video/mp4", + }; + await using var stream = File.OpenRead(dlg.FileName); + var url = await _session.Api.UploadMessageVideoAsync(stream, Path.GetFileName(dlg.FileName), contentType); + AttachedVideoUrls.Add(url); + ErrorMessage = null; + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + catch (IOException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsUploadingVideo = false; + } + } + + [RelayCommand] + private void RemoveVideoAttachment(string url) => AttachedVideoUrls.Remove(url); + + [RelayCommand] + private async Task ToggleScheduledAsync() + { + ShowScheduled = !ShowScheduled; + if (ShowScheduled) + await LoadScheduledAsync(); + } + + private async Task LoadScheduledAsync() + { + try + { + var scheduled = await _session.Api.GetScheduledMessagesAsync(); + ScheduledMessages.Clear(); + foreach (var m in scheduled) + ScheduledMessages.Add(new MessageItemViewModel(m, _session.Api, _session.CurrentUser?.Id)); + ErrorMessage = null; + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + } + [RelayCommand] private async Task LoadCrossPostOptionsAsync() { @@ -178,7 +268,8 @@ private async Task LoadMoreAsync() } } - private bool CanPost() => !IsPosting && (!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0); + private bool CanPost() => !IsPosting && + (!string.IsNullOrWhiteSpace(ComposeText) || AttachedImageUrls.Count > 0 || AttachedVideoUrls.Count > 0); [RelayCommand(CanExecute = nameof(CanPost))] private async Task PostAsync() @@ -192,13 +283,28 @@ await _session.Api.PostMessageAsync( crossPostToBluesky: CrossPostToBluesky, crossPostToTwitter: CrossPostToTwitter, mastodonProviderIds: CrossPostToMastodon ? MastodonProvider : null, - imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null); + scheduledAt: ResolveScheduledAt(), + imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null, + videoUrls: AttachedVideoUrls.Count > 0 ? AttachedVideoUrls.ToList() : null); ComposeText = ""; CrossPostToBluesky = false; CrossPostToTwitter = false; CrossPostToMastodon = false; AttachedImageUrls.Clear(); - await RefreshAsync(); + AttachedVideoUrls.Clear(); + var wasScheduled = IsScheduling; + IsScheduling = false; + // A scheduled post won't appear in the live feed — refresh the + // scheduled panel instead so the user sees it land. + if (wasScheduled) + { + await LoadScheduledAsync(); + ShowScheduled = true; + } + else + { + await RefreshAsync(); + } } catch (InterlinedApiException ex) { @@ -210,6 +316,14 @@ await _session.Api.PostMessageAsync( } } + private DateTimeOffset? ResolveScheduledAt() + { + if (!IsScheduling) return null; + var time = TimeSpan.TryParse(ScheduleTime, out var t) ? t : new TimeSpan(9, 0, 0); + var local = ScheduleDate.Date + time; + return new DateTimeOffset(local, TimeZoneInfo.Local.GetUtcOffset(local)); + } + partial void OnHasMoreChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged(); partial void OnIsLoadingChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged(); partial void OnIsLoadingMoreChanged(bool value) => LoadMoreCommand.NotifyCanExecuteChanged(); diff --git a/InterlinedList/ViewModels/ListsViewModel.cs b/InterlinedList/ViewModels/ListsViewModel.cs index 7477aa2..39a730f 100644 --- a/InterlinedList/ViewModels/ListsViewModel.cs +++ b/InterlinedList/ViewModels/ListsViewModel.cs @@ -15,6 +15,7 @@ public partial class ListsViewModel : ObservableObject public ObservableCollection Lists { get; } = new(); public ObservableCollection Rows { get; } = new(); + public ObservableCollection SharedWithMe { get; } = new(); [ObservableProperty] private bool isLoading; @@ -31,6 +32,12 @@ public partial class ListsViewModel : ObservableObject [ObservableProperty] private ListSummary? selectedList; + [ObservableProperty] + private bool isViewingShared; + + [ObservableProperty] + private WatchedList? selectedSharedList; + [ObservableProperty] private bool isLoadingRows; @@ -118,19 +125,51 @@ private async Task DeleteListAsync(ListSummary list) } } + [RelayCommand] + private async Task LoadSharedAsync() + { + try + { + var shared = await _session.Api.GetWatchingListsAsync(); + + SharedWithMe.Clear(); + foreach (var list in shared) + SharedWithMe.Add(list); + + ErrorMessage = null; + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + } + [RelayCommand] private async Task SelectListAsync(ListSummary list) { + IsViewingShared = false; + SelectedSharedList = null; SelectedList = list; - await LoadRowsAsync(list); + await LoadRowsAsync(list.Id); + } + + [RelayCommand] + private async Task SelectSharedListAsync(WatchedList watched) + { + IsViewingShared = true; + SelectedList = null; + SelectedSharedList = watched; + EditingRow = null; + EditRowJson = ""; + await LoadRowsAsync(watched.Id); } - private async Task LoadRowsAsync(ListSummary list) + private async Task LoadRowsAsync(string listId) { IsLoadingRows = true; try { - var page = await _session.Api.GetListDataAsync(list.Id, limit: PageSize, offset: 0); + var page = await _session.Api.GetListDataAsync(listId, limit: PageSize, offset: 0); Rows.Clear(); foreach (var row in page.Rows) @@ -172,7 +211,7 @@ private async Task AddRowAsync() await _session.Api.AddListRowAsync(list.Id, parsed); NewRowJson = ""; RowErrorMessage = null; - await LoadRowsAsync(list); + await LoadRowsAsync(list.Id); } catch (InterlinedApiException ex) { @@ -218,7 +257,7 @@ private async Task SaveRowEditAsync() EditingRow = null; EditRowJson = ""; RowErrorMessage = null; - await LoadRowsAsync(list); + await LoadRowsAsync(list.Id); } catch (InterlinedApiException ex) { @@ -233,7 +272,7 @@ private async Task DeleteRowAsync(ListDataRow row) try { await _session.Api.DeleteListRowAsync(list.Id, row.Id); - await LoadRowsAsync(list); + await LoadRowsAsync(list.Id); } catch (InterlinedApiException ex) { diff --git a/InterlinedList/ViewModels/MessageItemViewModel.cs b/InterlinedList/ViewModels/MessageItemViewModel.cs index cd1f081..d87b6f0 100644 --- a/InterlinedList/ViewModels/MessageItemViewModel.cs +++ b/InterlinedList/ViewModels/MessageItemViewModel.cs @@ -32,6 +32,9 @@ public partial class MessageItemViewModel : ObservableObject public IReadOnlyList ImageUrls { get; } public bool HasImages => ImageUrls.Count > 0; + public IReadOnlyList VideoUrls { get; } + public bool HasVideos => VideoUrls.Count > 0; + public ObservableCollection Replies { get; } = new(); [ObservableProperty] @@ -82,6 +85,7 @@ public MessageItemViewModel(Message message, InterlinedApiClient api, string? cu AvatarUrl = message.User?.Avatar; IsMine = message.UserId == currentUserId; ImageUrls = message.ImageUrls ?? new List(); + VideoUrls = message.VideoUrls ?? new List(); digCount = message.DigCount; dugByMe = message.DugByMe; @@ -256,6 +260,13 @@ private void OpenAuthor() Navigator.OpenProfile(AuthorUsername); } + [RelayCommand] + private void OpenVideo(string url) + { + if (!string.IsNullOrEmpty(url)) + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true }); + } + partial void OnEditTextChanged(string value) => SaveEditCommand.NotifyCanExecuteChanged(); partial void OnReplyTextChanged(string value) => PostReplyCommand.NotifyCanExecuteChanged(); } diff --git a/InterlinedList/Views/DirectMessagesView.xaml b/InterlinedList/Views/DirectMessagesView.xaml index 8d5a87f..ead0224 100644 --- a/InterlinedList/Views/DirectMessagesView.xaml +++ b/InterlinedList/Views/DirectMessagesView.xaml @@ -41,6 +41,33 @@ + + + @@ -236,6 +263,18 @@ Foreground="{DynamicResource TextBodyBrush}" TextWrapping="Wrap" LineHeight="20"/> + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + @@ -220,13 +283,55 @@ BorderThickness="0,0,0,1" Padding="20,12"> + + Foreground="{DynamicResource TextBrush}"> + + + + + + + + + + + + + Foreground="{DynamicResource TextMutedBrush}"> + + + + + + +