diff --git a/UniSky/Package.appxmanifest b/UniSky/Package.appxmanifest index a478ff6..1b729b4 100644 --- a/UniSky/Package.appxmanifest +++ b/UniSky/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="1.0.154.0" /> diff --git a/UniSky/Pages/NotificationsPage.xaml b/UniSky/Pages/NotificationsPage.xaml new file mode 100644 index 0000000..cddf834 --- /dev/null +++ b/UniSky/Pages/NotificationsPage.xaml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + diff --git a/UniSky/Pages/NotificationsPage.xaml.cs b/UniSky/Pages/NotificationsPage.xaml.cs new file mode 100644 index 0000000..9e561db --- /dev/null +++ b/UniSky/Pages/NotificationsPage.xaml.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Toolkit.Uwp.UI.Extensions; +using UniSky.Services; +using UniSky.ViewModels.Notifications; +using UniSky.ViewModels.Profile; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.Foundation.Metadata; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +namespace UniSky.Pages; + +public sealed partial class NotificationsPage : Page +{ + public NotificationsPageViewModel ViewModel + { + get { return (NotificationsPageViewModel)GetValue(ViewModelProperty); } + set { SetValue(ViewModelProperty, value); } + } + + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register("ViewModel", typeof(NotificationsPageViewModel), typeof(NotificationsPage), new PropertyMetadata(null)); + + public NotificationsPage() + { + this.InitializeComponent(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + var safeAreaService = ServiceContainer.Scoped.GetRequiredService(); + safeAreaService.SafeAreaUpdated += OnSafeAreaUpdated; + + if (this.ViewModel == null) + this.DataContext = this.ViewModel = ActivatorUtilities.CreateInstance(ServiceContainer.Scoped); + } + + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + base.OnNavigatedFrom(e); + + var safeAreaService = ServiceContainer.Scoped.GetRequiredService(); + safeAreaService.SafeAreaUpdated -= OnSafeAreaUpdated; + } + + private void OnSafeAreaUpdated(object sender, SafeAreaUpdatedEventArgs e) + { + TitleBarPadding.Height = new GridLength(e.SafeArea.Bounds.Top); + } + + private void RootList_ItemClick(object sender, ItemClickEventArgs e) + { + + } + + private void RootList_Loaded(object sender, RoutedEventArgs e) + { + if (ApiInformation.IsApiContractPresent(typeof(UniversalApiContract).FullName, 7)) + { + var scrollViewer = RootList.FindDescendant(); + scrollViewer.CanContentRenderOutsideBounds = true; + } + } +} diff --git a/UniSky/Pages/SearchPage.xaml b/UniSky/Pages/SearchPage.xaml index 07995cf..071d4cf 100644 --- a/UniSky/Pages/SearchPage.xaml +++ b/UniSky/Pages/SearchPage.xaml @@ -96,7 +96,7 @@ Following + + {0} liked your post + + + {0} and {1} liked your post + + + {0} followed you + + + {0} mentioned you + + + other + + + {0} quoted your post + + + {0} replied to you + + + {0} and {1} reposted + + + {0} reposted + \ No newline at end of file diff --git a/UniSky/Resources/en-GB/custom-twitter/Resources.resw b/UniSky/Resources/en-GB/custom-twitter/Resources.resw index 9eea7ef..c5394cf 100644 --- a/UniSky/Resources/en-GB/custom-twitter/Resources.resw +++ b/UniSky/Resources/en-GB/custom-twitter/Resources.resw @@ -162,4 +162,19 @@ NEW TWEET + + {0} and {1} liked your tweet + + + {0} liked your tweet + + + {0} and {1} retweeted + + + {0} retweeted + + + {0} quoted your tweet + \ No newline at end of file diff --git a/UniSky/UniSky.csproj b/UniSky/UniSky.csproj index d64bd46..d9db2c3 100644 --- a/UniSky/UniSky.csproj +++ b/UniSky/UniSky.csproj @@ -219,6 +219,9 @@ + + NotificationsPage.xaml + ProfilePage.xaml @@ -250,14 +253,17 @@ - + + + + - - - - + + + + @@ -441,6 +447,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/UniSky/ViewModels/HomeViewModel.cs b/UniSky/ViewModels/HomeViewModel.cs index 0575e17..4304c10 100644 --- a/UniSky/ViewModels/HomeViewModel.cs +++ b/UniSky/ViewModels/HomeViewModel.cs @@ -284,6 +284,8 @@ private void NavigateToPage() this.homeNavigationService.Navigate(); break; case HomePages.Notifications: + this.homeNavigationService.Navigate(); + break; case HomePages.Chat: this.homeNavigationService.Navigate(); break; diff --git a/UniSky/ViewModels/Notifications/NotificationViewModel.cs b/UniSky/ViewModels/Notifications/NotificationViewModel.cs new file mode 100644 index 0000000..3dd7689 --- /dev/null +++ b/UniSky/ViewModels/Notifications/NotificationViewModel.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using FishyFlip.Lexicon.App.Bsky.Embed; +using FishyFlip.Lexicon.App.Bsky.Feed; +using FishyFlip.Lexicon.App.Bsky.Notification; +using FishyFlip.Models; +using Humanizer; +using UniSky.ViewModels.Posts; +using UniSky.ViewModels.Profile; +using Windows.ApplicationModel.Resources; + +namespace UniSky.ViewModels.Notifications; + +public static class NotificationReason +{ + public const string Like = "like", + Repost = "repost", + Follow = "follow", + Mention = "mention", + Reply = "reply", + Quote = "quote", + StarterpackJoined = "starterpack-joined"; +} + +public partial class NotificationViewModel : ViewModelBase, IComparable, IComparable +{ + private class NotificationComparer : IEqualityComparer + { + public bool Equals(Notification x, Notification y) + { + return x?.Cid == y?.Cid; + } + + public int GetHashCode(Notification obj) + { + return obj.Cid.GetHashCode(); + } + } + + private readonly Post subjectPost; + private readonly ATIdentifier subjectPostAuthor; + + [ObservableProperty] + private string notificationTitle; + [ObservableProperty] + private string notificationSubtitle; + [ObservableProperty] + private DateTime timestamp; + [ObservableProperty] + private PostEmbedViewModel notificationEmbed; + [ObservableProperty] + private string avatarUrl; + + public ATUri Subject { get; } + public string Reason { get; } + + public bool ShowAvatar + => Reason is (NotificationReason.Follow or NotificationReason.Reply or NotificationReason.Quote); + public bool IsRetweet + => Reason == NotificationReason.Repost; + public bool IsLike + => Reason == NotificationReason.Like; + + public string Key => + string.Join('-', Reason, Subject); + public int Count => + Notifications.Count; + + public HashSet Notifications { get; } + = new HashSet(new NotificationComparer()); + + private Notification MostRecent + => Notifications.OrderByDescending(d => d.IndexedAt.Value) + .FirstOrDefault(); + + public NotificationViewModel(Notification notification, PostView post = null) + { + this.subjectPost = post?.Record as Post ?? notification.Record as Post; + this.subjectPostAuthor = post?.Author?.Did ?? notification.Author.Did; + Subject = notification.ReasonSubject; + Notifications.Add(notification); + Timestamp = notification.IndexedAt.Value; + Reason = notification.Reason; + Update(); + } + + public NotificationViewModel(IEnumerable notifications, PostView post = null) + : this(notifications.FirstOrDefault(), post) + { + foreach (var item in notifications) + Notifications.Add(item); + + Timestamp = MostRecent.IndexedAt.Value; + Update(); + } + + internal void Add(IEnumerable items) + { + foreach (var item in items) + Notifications.Add(item); + + Timestamp = MostRecent.IndexedAt.Value; + Update(); + } + + private void Update() + { + var resources = ResourceLoader.GetForCurrentView(); + var other = resources.GetString("NotificationOther"); + var mostRecentAuthor = new ProfileViewModel(MostRecent.Author); + + switch (Reason) + { + case NotificationReason.Like: + { + if (Count == 1) + NotificationTitle = string.Format(resources.GetString("NotificationLikedTweetSingle"), mostRecentAuthor.Name); + else + NotificationTitle = string.Format(resources.GetString("NotificationLikedTweetMultiple"), mostRecentAuthor.Name, other.ToQuantity(Count - 1)); + + break; + } + case NotificationReason.Repost: + { + if (Count == 1) + NotificationTitle = string.Format(resources.GetString("NotificationRetweetSingle"), mostRecentAuthor.Name); + else + NotificationTitle = string.Format(resources.GetString("NotificationRetweetMultiple"), mostRecentAuthor.Name, other.ToQuantity(Count - 1)); + + break; + } + case NotificationReason.Follow: + NotificationTitle = string.Format(resources.GetString("NotificationFollow"), mostRecentAuthor.Name); + break; + case NotificationReason.Reply: + NotificationTitle = string.Format(resources.GetString("NotificationReply"), mostRecentAuthor.Name); + break; + case NotificationReason.Mention: + NotificationTitle = string.Format(resources.GetString("NotificationMention"), mostRecentAuthor.Name); + break; + case NotificationReason.Quote: + NotificationTitle = string.Format(resources.GetString("NotificationQuote"), mostRecentAuthor.Name); + break; + } + + AvatarUrl = mostRecentAuthor.AvatarUrl; + NotificationSubtitle = subjectPost?.Text; + if (subjectPost is { Embed: EmbedImages and { } images }) + { + NotificationEmbed = new PostEmbedImagesViewModel(subjectPostAuthor, images); + } + } + + public int CompareTo(object obj) + { + return -((IComparable)Timestamp).CompareTo(((NotificationViewModel)obj).Timestamp); + } + + public int CompareTo(NotificationViewModel other) + { + return Timestamp.CompareTo(other.Timestamp); + } +} diff --git a/UniSky/ViewModels/Notifications/NotificationsCollection.cs b/UniSky/ViewModels/Notifications/NotificationsCollection.cs new file mode 100644 index 0000000..7842fce --- /dev/null +++ b/UniSky/ViewModels/Notifications/NotificationsCollection.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FishyFlip.Lexicon.App.Bsky.Feed; +using FishyFlip.Lexicon.App.Bsky.Notification; +using FishyFlip.Tools; +using UniSky.Services; +using Windows.Foundation; +using Windows.UI.Core; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Data; + +namespace UniSky.ViewModels.Notifications; + +public class NotificationsCollection : ObservableCollection, ISupportIncrementalLoading +{ + private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1); + private readonly CoreDispatcher dispatcher = Window.Current.Dispatcher; + + private readonly NotificationsPageViewModel parent; + private readonly IProtocolService protocolService; + + private string cursor; + + public NotificationsCollection(NotificationsPageViewModel parent, IProtocolService protocolService) + { + this.parent = parent; + this.protocolService = protocolService; + } + public bool HasMoreItems { get; private set; } = true; + + + public IAsyncOperation LoadMoreItemsAsync(uint count) + { + return Task.Run(async () => + { + try + { + await semaphore.WaitAsync(); + return await InternalLoadMoreItemsAsync((int)count); + } + finally + { + semaphore.Release(); + } + }).AsAsyncOperation(); + } + + private async Task InternalLoadMoreItemsAsync(int count) + { + var service = protocolService.Protocol; + var viewModel = parent; + viewModel.Error = null; + + count = Math.Clamp(count, 5, 25); + + using var context = viewModel.GetLoadingContext(); + + try + { + var notifications = (await service.ListNotificationsAsync(count, cursor: cursor) + .ConfigureAwait(false)) + .HandleResult(); + + this.cursor = notifications.Cursor; + + var hydratePostIds = notifications.Notifications.Where(n => + n.Reason is (NotificationReason.Like or NotificationReason.Repost) && + n.ReasonSubject is not null) + .Select(s => s.ReasonSubject) + .Distinct(); + + var posts = (await service.GetPostsAsync(hydratePostIds.ToList()) + .ConfigureAwait(false)) + .HandleResult() + .Posts + .ToDictionary(k => k.Uri.ToString()); + + var initialCount = Count; + + await dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => + { + var groups = notifications.Notifications + .GroupBy(g => (g.Reason is (NotificationReason.Like or NotificationReason.Repost)) ? string.Join('-', g.Reason, g.ReasonSubject) : null); + + foreach (var group in groups) + { + NotificationViewModel viewModel; + if (group.Key != null && (viewModel = this.FirstOrDefault(v => v.Subject != null && v.Key == group.Key)) != null) + { + viewModel.Add(group); + continue; + } + + Notification notification = null; + PostView post = null; + if (group.Key == null) + { + foreach (var ungroupedNotification in group) + { + notification = ungroupedNotification; + if (notification.Reason is (NotificationReason.Like or NotificationReason.Repost)) + _ = posts.TryGetValue(notification.ReasonSubject.ToString(), out post); + + Add(new NotificationViewModel(notification, post)); + } + + continue; + } + + notification = group.FirstOrDefault(); + post = null; + + if (notification.Reason is (NotificationReason.Like or NotificationReason.Repost)) + _ = posts.TryGetValue(notification.ReasonSubject.ToString(), out post); + + Add(new NotificationViewModel(group, post)); + } + + ArrayList.Adapter(this).Sort(); // ????? + }); + + if (notifications.Notifications.Count == 0 || string.IsNullOrWhiteSpace(this.cursor)) + HasMoreItems = false; + + return new LoadMoreItemsResult() { Count = (uint)(Count - initialCount) }; + } + catch (Exception ex) + { + HasMoreItems = false; + return new LoadMoreItemsResult() { Count = 0 }; + } + } +} diff --git a/UniSky/ViewModels/Notifications/NotificationsPageViewModel.cs b/UniSky/ViewModels/Notifications/NotificationsPageViewModel.cs new file mode 100644 index 0000000..e111ab2 --- /dev/null +++ b/UniSky/ViewModels/Notifications/NotificationsPageViewModel.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FishyFlip.Lexicon.App.Bsky.Actor; +using FishyFlip.Tools; +using Microsoft.Extensions.Logging; +using UniSky.Services; +using Windows.UI.Xaml.Data; + +namespace UniSky.ViewModels.Notifications; + +public partial class NotificationsPageViewModel : ViewModelBase +{ + private IProtocolService protocolService; + + + public NotificationsCollection Notifications { get; } + + public NotificationsPageViewModel(IProtocolService protocolService) + { + this.protocolService = protocolService; + this.Notifications = new NotificationsCollection(this, protocolService); + } +} diff --git a/UniSky/ViewModels/Post/PostEmbedImageViewModel.cs b/UniSky/ViewModels/Posts/PostEmbedImageViewModel.cs similarity index 66% rename from UniSky/ViewModels/Post/PostEmbedImageViewModel.cs rename to UniSky/ViewModels/Posts/PostEmbedImageViewModel.cs index 53f7fae..ec15fde 100644 --- a/UniSky/ViewModels/Post/PostEmbedImageViewModel.cs +++ b/UniSky/ViewModels/Posts/PostEmbedImageViewModel.cs @@ -9,6 +9,11 @@ public partial class PostEmbedImageViewModel : ViewModelBase [ObservableProperty] private string thumbnailUrl; + public PostEmbedImageViewModel(ATIdentifier id, Image image) + { + ThumbnailUrl = $"https://cdn.bsky.app/img/feed_thumbnail/plain/{id}/{image.ImageValue.Ref.Link}@jpeg"; + } + public PostEmbedImageViewModel(ViewImage image) { ThumbnailUrl = image.Thumb; diff --git a/UniSky/ViewModels/Post/PostEmbedImagesViewModel.cs b/UniSky/ViewModels/Posts/PostEmbedImagesViewModel.cs similarity index 77% rename from UniSky/ViewModels/Post/PostEmbedImagesViewModel.cs rename to UniSky/ViewModels/Posts/PostEmbedImagesViewModel.cs index 868c793..25f20d9 100644 --- a/UniSky/ViewModels/Post/PostEmbedImagesViewModel.cs +++ b/UniSky/ViewModels/Posts/PostEmbedImagesViewModel.cs @@ -35,6 +35,20 @@ public PostEmbedImageViewModel Image4 public bool IsThree => Count == 3; public bool IsFour => Count == 4; + public PostEmbedImagesViewModel(ATIdentifier id, EmbedImages embed) : base(embed) + { + Count = embed.Images.Count; + Images = embed.Images.Select(i => new PostEmbedImageViewModel(id, i)).ToArray(); + + // this would be problematic + Debug.Assert(Images.Length > 0 && Images.Length <= 4); + Debug.Assert(embed.Images.Count == Images.Length); + Debug.Assert(Images.Length == Count); + + var firstRatio = embed.Images[0].AspectRatio; + SetAspectRatio(firstRatio); + } + public PostEmbedImagesViewModel(ViewImages embed) : base(embed) { Count = embed.Images.Count; @@ -46,6 +60,11 @@ public PostEmbedImagesViewModel(ViewImages embed) : base(embed) Debug.Assert(Images.Length == Count); var firstRatio = embed.Images[0].AspectRatio; + SetAspectRatio(firstRatio); + } + + private void SetAspectRatio(AspectRatio firstRatio) + { if (Images.Length == 1 && firstRatio == null) { AspectRatio = new(); diff --git a/UniSky/ViewModels/Post/PostEmbedVideoViewModel.cs b/UniSky/ViewModels/Posts/PostEmbedVideoViewModel.cs similarity index 100% rename from UniSky/ViewModels/Post/PostEmbedVideoViewModel.cs rename to UniSky/ViewModels/Posts/PostEmbedVideoViewModel.cs diff --git a/UniSky/ViewModels/Post/PostEmbedViewModel.cs b/UniSky/ViewModels/Posts/PostEmbedViewModel.cs similarity index 100% rename from UniSky/ViewModels/Post/PostEmbedViewModel.cs rename to UniSky/ViewModels/Posts/PostEmbedViewModel.cs diff --git a/UniSky/ViewModels/Post/PostViewModel.cs b/UniSky/ViewModels/Posts/PostViewModel.cs similarity index 98% rename from UniSky/ViewModels/Post/PostViewModel.cs rename to UniSky/ViewModels/Posts/PostViewModel.cs index f36a84d..275311a 100644 --- a/UniSky/ViewModels/Post/PostViewModel.cs +++ b/UniSky/ViewModels/Posts/PostViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -17,14 +16,11 @@ using Microsoft.Extensions.DependencyInjection; using UniSky.Controls.Compose; using UniSky.Helpers; -using UniSky.Pages; using UniSky.Services; using UniSky.ViewModels.Profile; using UniSky.ViewModels.Text; -using Windows.ApplicationModel; using Windows.ApplicationModel.DataTransfer; using Windows.UI.Xaml; -using Windows.UI.Xaml.Media.Animation; namespace UniSky.ViewModels.Posts;