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;