From 8b8650797e97d5a341cfb941f7f98629a05b3e16 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Tue, 3 Dec 2024 23:41:50 +0000 Subject: [PATCH] Support facets in posts --- UniSky/App.xaml | 1 + UniSky/App.xaml.cs | 26 ++- .../Controls/RichTextBlock/RichTextBlock.cs | 156 ++++++++++++++++++ UniSky/DataTemplates/FeedTemplates.xaml | 34 ++-- UniSky/Package.appxmanifest | 8 + UniSky/Pages/ProfilePage.xaml.cs | 20 ++- UniSky/RootPage.xaml | 3 +- UniSky/RootPage.xaml.cs | 10 +- UniSky/Services/NavigationService.cs | 1 + UniSky/Services/NavigationServiceLocator.cs | 2 +- UniSky/Templates/RichTextBlockStyles.xaml | 18 ++ UniSky/UniSky.csproj | 14 ++ UniSky/ViewModels/HomeViewModel.cs | 73 ++++---- UniSky/ViewModels/Post/PostViewModel.cs | 25 ++- .../Profile/ProfilePageViewModel.cs | 58 +++++-- UniSky/ViewModels/Text/RichTextViewModel.cs | 119 +++++++++++++ 16 files changed, 484 insertions(+), 84 deletions(-) create mode 100644 UniSky/Controls/RichTextBlock/RichTextBlock.cs create mode 100644 UniSky/Templates/RichTextBlockStyles.xaml create mode 100644 UniSky/ViewModels/Text/RichTextViewModel.cs diff --git a/UniSky/App.xaml b/UniSky/App.xaml index 71b111f..4d877b5 100644 --- a/UniSky/App.xaml +++ b/UniSky/App.xaml @@ -20,6 +20,7 @@ + diff --git a/UniSky/App.xaml.cs b/UniSky/App.xaml.cs index 4924530..2c52097 100644 --- a/UniSky/App.xaml.cs +++ b/UniSky/App.xaml.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using UniSky.Extensions; using UniSky.Helpers.Localisation; +using UniSky.Pages; using UniSky.Services; using Windows.ApplicationModel; using Windows.ApplicationModel.Activation; @@ -64,7 +65,15 @@ private void ConfigureServices() Ioc.Default.ConfigureServices(collection.BuildServiceProvider()); Configurator.Formatters.Register("en", (locale) => new ShortTimespanFormatter("en")); - } + } + + protected override void OnActivated(IActivatedEventArgs args) + { + if (args is ProtocolActivatedEventArgs e) + { + this.OnProtocolActivated(e); + } + } /// /// Invoked when the application is launched normally by the end user. Other entry points @@ -108,6 +117,21 @@ protected override void OnLaunched(LaunchActivatedEventArgs e) } } + private void OnProtocolActivated(ProtocolActivatedEventArgs e) + { + Hairline.Initialize(); + if (Window.Current.Content is not Frame rootFrame) + { + rootFrame = new Frame(); + rootFrame.NavigationFailed += OnNavigationFailed; + rootFrame.Navigate(typeof(RootPage)); + Window.Current.Content = rootFrame; + } + + // Ensure the current window is active + Window.Current.Activate(); + } + /// /// Invoked when Navigation to a certain page fails /// diff --git a/UniSky/Controls/RichTextBlock/RichTextBlock.cs b/UniSky/Controls/RichTextBlock/RichTextBlock.cs new file mode 100644 index 0000000..d51c79a --- /dev/null +++ b/UniSky/Controls/RichTextBlock/RichTextBlock.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using CommunityToolkit.Mvvm.DependencyInjection; +using Microsoft.Toolkit.Uwp.UI.Extensions; +using UniSky.Pages; +using UniSky.Services; +using UniSky.ViewModels.Text; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Documents; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; + +// The Templated Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234235 + +namespace UniSky.Controls +{ + [TemplatePart(Name = "PART_TextBlock", Type = typeof(TextBlock))] + public sealed class RichTextBlock : Control + { + private static readonly DependencyProperty HyperlinkUrlProperty = + DependencyProperty.RegisterAttached("HyperlinkUrl", typeof(Uri), typeof(RichTextBlock), new PropertyMetadata(null)); + + public TextWrapping TextWrapping + { + get { return (TextWrapping)GetValue(TextWrappingProperty); } + set { SetValue(TextWrappingProperty, value); } + } + + public static readonly DependencyProperty TextWrappingProperty = + DependencyProperty.Register("TextWrapping", typeof(TextWrapping), typeof(RichTextBlock), new PropertyMetadata(TextWrapping.Wrap)); + + public bool IsTextSelectionEnabled + { + get { return (bool)GetValue(IsTextSelectionEnabledProperty); } + set { SetValue(IsTextSelectionEnabledProperty, value); } + } + + public static readonly DependencyProperty IsTextSelectionEnabledProperty = + DependencyProperty.Register("IsTextSelectionEnabled", typeof(bool), typeof(RichTextBlock), new PropertyMetadata(true)); + + public IList Inlines + { + get { return (IList)GetValue(InlinesProperty); } + set { SetValue(InlinesProperty, value); } + } + + public static readonly DependencyProperty InlinesProperty = + DependencyProperty.Register("Inlines", typeof(IList), typeof(RichTextBlock), new PropertyMetadata(null, OnInlinesChanged)); + + private static void OnInlinesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not RichTextBlock text) + return; + + text.UpdateInlines(); + } + + public RichTextBlock() + { + this.DefaultStyleKey = typeof(RichTextBlock); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + UpdateInlines(); + } + + private void UpdateInlines() + { + this.ApplyTemplate(); + + if (this.FindDescendantByName("PART_TextBlock") is not TextBlock text) + return; + + text.Inlines.Clear(); + + // TODO: this could be cleaner + foreach (var inline in Inlines) + { + var mention = inline.Properties.OfType() + .FirstOrDefault(); + if (mention != null) + { + var hyperlink = new Hyperlink() + { + Inlines = { new Run() { Text = inline.Text } } + }; + + hyperlink.Click += Hyperlink_Click; + hyperlink.SetValue(HyperlinkUrlProperty, new Uri("unisky:///profile/" + mention.Did.ToString())); + + text.Inlines.Add(hyperlink); + continue; + } + + var tag = inline.Properties.OfType() + .FirstOrDefault(); + if (tag != null) + { + var hyperlink = new Hyperlink() + { + Inlines = { new Run() { Text = inline.Text } } + }; + + hyperlink.Click += Hyperlink_Click; + hyperlink.SetValue(HyperlinkUrlProperty, new Uri("unisky:///tag/" + tag.Tag)); + + text.Inlines.Add(hyperlink); + continue; + } + + var link = inline.Properties.OfType() + .FirstOrDefault(); + if (link != null && Uri.TryCreate(link.Url, UriKind.Absolute, out var uri)) + { + var hyperlink = new Hyperlink() + { + NavigateUri = uri, + Inlines = { new Run() { Text = inline.Text } } + }; + + text.Inlines.Add(hyperlink); + continue; + } + + text.Inlines.Add(new Run() { Text = inline.Text }); + } + } + + // TODO: move this somewhere better + private void Hyperlink_Click(Hyperlink sender, HyperlinkClickEventArgs args) + { + if (sender.GetValue(HyperlinkUrlProperty) is not Uri { Scheme: "unisky" } uri) + return; + + var service = Ioc.Default.GetRequiredService() + .GetNavigationService("Home"); + + var path = uri.PathAndQuery.Split('/', StringSplitOptions.RemoveEmptyEntries); + switch (path.FirstOrDefault()?.ToLowerInvariant()) + { + case "profile": + service.Navigate(uri); + break; + case "tag": + break; + } + } + } +} \ No newline at end of file diff --git a/UniSky/DataTemplates/FeedTemplates.xaml b/UniSky/DataTemplates/FeedTemplates.xaml index 4764200..b3efc6b 100644 --- a/UniSky/DataTemplates/FeedTemplates.xaml +++ b/UniSky/DataTemplates/FeedTemplates.xaml @@ -5,12 +5,13 @@ xmlns:not1809="http://schemas.microsoft.com/winfx/2006/xaml/presentation?IsApiContractNotPresent(Windows.Foundation.UniversalApiContract, 7)" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:core="using:Microsoft.Xaml.Interactions.Core" + xmlns:controls="using:UniSky.Controls" xmlns:datatemplates="using:UniSky.DataTemplates" xmlns:converters="using:UniSky.Converters" xmlns:extensions="using:UniSky.Extensions" xmlns:feeds="using:UniSky.ViewModels.Feeds" xmlns:posts="using:UniSky.ViewModels.Posts" - xmlns:controls="using:Microsoft.Toolkit.Uwp.UI.Controls" + xmlns:toolkit="using:Microsoft.Toolkit.Uwp.UI.Controls" x:DefaultBindMode="OneWay"> @@ -134,7 +135,7 @@ - + @@ -151,7 +152,7 @@ Padding="0" Style="{ThemeResource CleanButtonStyle}"> @@ -162,7 +163,7 @@ Padding="0" Style="{ThemeResource CleanButtonStyle}"> @@ -173,7 +174,7 @@ Padding="0" Style="{ThemeResource CleanButtonStyle}"> @@ -184,18 +185,18 @@ Padding="0" Style="{ThemeResource CleanButtonStyle}"> - + - + - + - + + + + + + Unisky + Assets\Square44x44Logo.png + + + diff --git a/UniSky/Pages/ProfilePage.xaml.cs b/UniSky/Pages/ProfilePage.xaml.cs index 19288ea..6e04ee0 100644 --- a/UniSky/Pages/ProfilePage.xaml.cs +++ b/UniSky/Pages/ProfilePage.xaml.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using System.Numerics; +using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.DependencyInjection; using FishyFlip.Lexicon; using FishyFlip.Lexicon.App.Bsky.Actor; @@ -56,10 +58,12 @@ protected override void OnNavigatedTo(NavigationEventArgs e) { base.OnNavigatedTo(e); - if (e.Parameter is not (ProfileViewBasic or ProfileViewDetailed)) + if (e.Parameter is not (ProfileViewBasic or ProfileViewDetailed or Uri)) return; - if (e.Parameter is ATObject basic) + if (e.Parameter is Uri { Scheme: "unisky" } uri) + HandleUniskyProtocol(uri); + else if (e.Parameter is ATObject basic) this.DataContext = ActivatorUtilities.CreateInstance(Ioc.Default, basic); var safeAreaService = Ioc.Default.GetRequiredService(); @@ -72,6 +76,18 @@ protected override void OnNavigatedFrom(NavigationEventArgs e) safeAreaService.SafeAreaUpdated -= OnSafeAreaUpdated; } + private void HandleUniskyProtocol(Uri uri) + { + var path = uri.PathAndQuery.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (path.Length < 2 || !string.Equals(path[0], "profile", StringComparison.InvariantCultureIgnoreCase)) + { + this.Frame.Navigate(typeof(FeedsPage)); + } + + if (ATDid.TryCreate(path[1], out var did)) + this.DataContext = ActivatorUtilities.CreateInstance(Ioc.Default, did); + } + private void Page_Loaded(object sender, RoutedEventArgs e) { // Retrieve the ScrollViewer that the GridView is using internally diff --git a/UniSky/RootPage.xaml b/UniSky/RootPage.xaml index 66a0073..36d7b77 100644 --- a/UniSky/RootPage.xaml +++ b/UniSky/RootPage.xaml @@ -8,7 +8,8 @@ xmlns:viewmodels="using:UniSky.ViewModels" xmlns:pages="using:UniSky.Pages" xmlns:sheet="using:UniSky.Controls.Sheet" - mc:Ignorable="d"> + mc:Ignorable="d" + NavigationCacheMode="Required"> diff --git a/UniSky/RootPage.xaml.cs b/UniSky/RootPage.xaml.cs index 0cbcfee..660a65b 100644 --- a/UniSky/RootPage.xaml.cs +++ b/UniSky/RootPage.xaml.cs @@ -47,16 +47,17 @@ public RootPage() Loaded += RootPage_Loaded; } - private void RootPage_Loaded(object sender, RoutedEventArgs e) + protected override void OnNavigatedTo(NavigationEventArgs e) { + base.OnNavigatedTo(e); + var serviceLocator = Ioc.Default.GetRequiredService(); var service = serviceLocator.GetNavigationService("Root"); service.Frame = RootFrame; var sessionService = Ioc.Default.GetRequiredService(); if (ApplicationData.Current.LocalSettings.Values.TryGetValue("LastUsedUser", out var userObj) && - userObj is string user && - sessionService.TryFindSession(user, out var session)) + userObj is string user) { service.Navigate(user); } @@ -64,7 +65,10 @@ userObj is string user && { service.Navigate(); } + } + private void RootPage_Loaded(object sender, RoutedEventArgs e) + { BirdAnimation.RunBirdAnimation(SheetRoot); } } diff --git a/UniSky/Services/NavigationService.cs b/UniSky/Services/NavigationService.cs index c64c9f5..6095b9d 100644 --- a/UniSky/Services/NavigationService.cs +++ b/UniSky/Services/NavigationService.cs @@ -6,6 +6,7 @@ using Windows.UI.Xaml.Media.Animation; using Windows.UI.Xaml.Navigation; using Windows.UI.Xaml; +using System.Collections.Generic; namespace UniSky.Services; diff --git a/UniSky/Services/NavigationServiceLocator.cs b/UniSky/Services/NavigationServiceLocator.cs index d140abf..b3cc916 100644 --- a/UniSky/Services/NavigationServiceLocator.cs +++ b/UniSky/Services/NavigationServiceLocator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; + using System.Collections.Generic; using CommunityToolkit.Mvvm.DependencyInjection; using Microsoft.Extensions.DependencyInjection; diff --git a/UniSky/Templates/RichTextBlockStyles.xaml b/UniSky/Templates/RichTextBlockStyles.xaml new file mode 100644 index 0000000..34677e8 --- /dev/null +++ b/UniSky/Templates/RichTextBlockStyles.xaml @@ -0,0 +1,18 @@ + + + + diff --git a/UniSky/UniSky.csproj b/UniSky/UniSky.csproj index dfb4a11..0b6d702 100644 --- a/UniSky/UniSky.csproj +++ b/UniSky/UniSky.csproj @@ -48,6 +48,7 @@ false prompt true + true bin\x86\Release\ @@ -60,6 +61,7 @@ prompt true true + true true @@ -72,6 +74,7 @@ prompt true true + true bin\ARM\Release\ @@ -84,6 +87,7 @@ prompt true true + true true @@ -96,6 +100,7 @@ prompt true true + true bin\ARM64\Release\ @@ -108,6 +113,7 @@ prompt true true + true true @@ -120,6 +126,7 @@ prompt true false + true bin\x64\Release\ @@ -132,6 +139,7 @@ prompt true true + true PackageReference @@ -150,6 +158,7 @@ + SheetRootControl.xaml @@ -230,6 +239,7 @@ + FeedsPage.xaml @@ -448,6 +458,10 @@ MSBuild:Compile Designer + + MSBuild:Compile + Designer + MSBuild:Compile Designer diff --git a/UniSky/ViewModels/HomeViewModel.cs b/UniSky/ViewModels/HomeViewModel.cs index 7abceea..e7df101 100644 --- a/UniSky/ViewModels/HomeViewModel.cs +++ b/UniSky/ViewModels/HomeViewModel.cs @@ -79,7 +79,7 @@ public bool ProfileSelected => Page == HomePages.Profile; public HomeViewModel( - string session, + string profile, SessionService sessionService, INavigationServiceLocator navigationServiceLocator, IProtocolService protocolService, @@ -89,13 +89,13 @@ public HomeViewModel( this.rootNavigationService = navigationServiceLocator.GetNavigationService("Root"); this.homeNavigationService = navigationServiceLocator.GetNavigationService("Home"); - if (!sessionService.TryFindSession(session, out var sessionModel)) + if (!sessionService.TryFindSession(profile, out var sessionModel)) { rootNavigationService.Navigate(); return; } - ApplicationData.Current.LocalSettings.Values["LastUsedUser"] = session; + ApplicationData.Current.LocalSettings.Values["LastUsedUser"] = profile; this.sessionService = sessionService; this.logger = logger; @@ -134,31 +134,8 @@ private async Task LoadAsync() try { - // to ensure the session gets refreshed properly: - // - initially authenticate the client with the refresh token - // - refresh the sesssion - // - reauthenticate with the new session - - var sessionRefresh = sessionModel.Session.Session; - var authSessionRefresh = new AuthSession( - new Session(sessionRefresh.Did, sessionRefresh.DidDoc, sessionRefresh.Handle, null, sessionRefresh.RefreshJwt, sessionRefresh.RefreshJwt)); - - await protocol.AuthenticateWithPasswordSessionAsync(authSessionRefresh); - var refreshSession = (await protocol.RefreshSessionAsync().ConfigureAwait(false)) - .HandleResult(); - - var authSession2 = new AuthSession( - new Session(refreshSession.Did, refreshSession.DidDoc, refreshSession.Handle, null, refreshSession.AccessJwt, refreshSession.RefreshJwt)); - var session2 = await protocol.AuthenticateWithPasswordSessionAsync(authSession2).ConfigureAwait(false); - - if (session2 == null) - throw new InvalidOperationException("Authentication failed!"); - - var sessionModel2 = new SessionModel(true, sessionModel.Service, authSession2.Session, authSession2); - var sessionService = Ioc.Default.GetRequiredService(); - sessionService.SaveSession(sessionModel2); - - protocolService.SetProtocol(protocol); + await RefreshSessionAsync(protocol) + .ConfigureAwait(false); } catch (Exception ex) { @@ -176,13 +153,10 @@ await Task.WhenAll(UpdateProfileAsync(), UpdateNotificationsAsync()) this.syncContext.Post(() => notificationUpdateTimer.Start()); } - catch (ATNetworkErrorException ex) + catch (ATNetworkErrorException ex) when (ex is { AtError.Detail.Error: "ExpiredToken" }) { - if (ex.AtError?.Detail?.Error == "ExpiredToken") - { - this.syncContext.Post(() => rootNavigationService.Navigate()); - return; - } + this.syncContext.Post(() => rootNavigationService.Navigate()); + return; } catch (Exception ex) { @@ -191,6 +165,37 @@ await Task.WhenAll(UpdateProfileAsync(), UpdateNotificationsAsync()) } } + private async Task RefreshSessionAsync(ATProtocol protocol) + { + // to ensure the session gets refreshed properly: + // - initially authenticate the client with the refresh token + // - refresh the sesssion + // - reauthenticate with the new session + + var sessionRefresh = sessionModel.Session.Session; + var authSessionRefresh = new AuthSession( + new Session(sessionRefresh.Did, sessionRefresh.DidDoc, sessionRefresh.Handle, null, sessionRefresh.RefreshJwt, sessionRefresh.RefreshJwt)); + + await protocol.AuthenticateWithPasswordSessionAsync(authSessionRefresh); + var refreshSession = (await protocol.RefreshSessionAsync() + .ConfigureAwait(false)) + .HandleResult(); + + var authSession2 = new AuthSession( + new Session(refreshSession.Did, refreshSession.DidDoc, refreshSession.Handle, null, refreshSession.AccessJwt, refreshSession.RefreshJwt)); + var session2 = await protocol.AuthenticateWithPasswordSessionAsync(authSession2) + .ConfigureAwait(false); + + if (session2 == null) + throw new InvalidOperationException("Authentication failed!"); + + var sessionModel2 = new SessionModel(true, sessionModel.Service, authSession2.Session, authSession2); + var sessionService = Ioc.Default.GetRequiredService(); + sessionService.SaveSession(sessionModel2); + + protocolService.SetProtocol(protocol); + } + private async Task UpdateProfileAsync() { var protocol = protocolService.Protocol; diff --git a/UniSky/ViewModels/Post/PostViewModel.cs b/UniSky/ViewModels/Post/PostViewModel.cs index e5e8052..1f012fb 100644 --- a/UniSky/ViewModels/Post/PostViewModel.cs +++ b/UniSky/ViewModels/Post/PostViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Web; @@ -19,6 +20,7 @@ using UniSky.Pages; using UniSky.Services; using UniSky.ViewModels.Profiles; +using UniSky.ViewModels.Text; using Windows.ApplicationModel; using Windows.ApplicationModel.DataTransfer; using Windows.UI.Xaml; @@ -34,8 +36,6 @@ public partial class PostViewModel : ViewModelBase private ATUri like; private ATUri repost; - [ObservableProperty] - private string text; [ObservableProperty] private ProfileViewModel author; @@ -74,8 +74,16 @@ public partial class PostViewModel : ViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(Borders))] private bool hasChild; - public Thickness Borders - => HasChild ? new Thickness() : new Thickness(0, 0, 0, 1); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Text))] + private RichTextViewModel richText; + + public Post Post => post; + public PostView View => view; + + public string Text + => string.Concat(RichText.Facets.Select(s => s.Text)); public string Likes => ToNumberString(LikeCount); @@ -91,9 +99,8 @@ public bool ShowReplyContainer => ReplyTo != null && !HasParent; public bool ShowReplyLine => HasChild; - - public Post Post => post; - public PostView View => view; + public Thickness Borders + => HasChild ? new Thickness() : new Thickness(0, 0, 0, 1); public PostViewModel(FeedViewPost feedPost, bool hasParent = false) : this(feedPost.Post) @@ -122,9 +129,11 @@ public PostViewModel(PostView view, bool hasChild = false) HasChild = hasChild; Author = new ProfileViewModel(view.Author); - Text = post.Text; Embed = CreateEmbedViewModel(view.Embed); + RichText = new RichTextViewModel(post.Text, post.Facets ?? []); + Debug.WriteLine(string.Concat(RichText.Facets.Select(f => f.Text))); + var timeSinceIndex = DateTime.Now - (view.IndexedAt.Value.ToLocalTime()); var date = timeSinceIndex.Humanize(1, minUnit: Humanizer.Localisation.TimeUnit.Second); Date = date; diff --git a/UniSky/ViewModels/Profile/ProfilePageViewModel.cs b/UniSky/ViewModels/Profile/ProfilePageViewModel.cs index 213c64f..f97ba55 100644 --- a/UniSky/ViewModels/Profile/ProfilePageViewModel.cs +++ b/UniSky/ViewModels/Profile/ProfilePageViewModel.cs @@ -58,23 +58,31 @@ public string Posts public ProfilePageViewModel() : base() { } - public ProfilePageViewModel(ATObject profile, IProtocolService protocolService) + public ProfilePageViewModel(ATDid did) + { + this.id = did; + + Feeds = []; + SelectedFeed = null; + Task.Run(LoadAsync); + } + + public ProfilePageViewModel(ATObject profile) : base(profile) { + var protocol = Ioc.Default.GetRequiredService(); if (profile is ProfileViewDetailed detailed) { Populate(detailed); } - else - { - _ = Task.Run(LoadAsync); - } + + Task.Run(LoadAsync); Feeds = [ - new ProfileFeedViewModel(this, "posts_no_replies", profile, protocolService), - new ProfileFeedViewModel(this, "posts_with_replies", profile, protocolService), - new ProfileFeedViewModel(this, "posts_with_media", profile, protocolService) + new ProfileFeedViewModel(this, "posts_no_replies", profile, protocol), + new ProfileFeedViewModel(this, "posts_with_replies", profile, protocol), + new ProfileFeedViewModel(this, "posts_with_media", profile, protocol) ]; SelectedFeed = Feeds[0]; @@ -86,22 +94,36 @@ private async Task LoadAsync() { using var context = this.GetLoadingContext(); - var protocol = Ioc.Default.GetRequiredService() - .Protocol; - - var profile = (await protocol.GetProfileAsync(this.id).ConfigureAwait(false)) + var protocol = Ioc.Default.GetRequiredService(); + var profile = (await protocol.Protocol.GetProfileAsync(this.id).ConfigureAwait(false)) .HandleResult(); - Populate(profile); + syncContext.Post(() => + { + if (Feeds.Count == 0) + { + Feeds.Add(new ProfileFeedViewModel(this, "posts_no_replies", profile, protocol)); + Feeds.Add(new ProfileFeedViewModel(this, "posts_with_replies", profile, protocol)); + Feeds.Add(new ProfileFeedViewModel(this, "posts_with_media", profile, protocol)); + + SelectedFeed = Feeds[0]; + } + + Populate(profile); + }); } private void Populate(ProfileViewDetailed profile) { - BannerUrl = profile.Banner; - FollowerCount = (int)profile.FollowersCount; - FollowingCount = (int)profile.FollowsCount; - PostCount = (int)profile.PostsCount; - Bio = profile.Description?.Trim(); + this.id = profile.Did; + this.AvatarUrl = profile.Avatar; + this.Name = profile.DisplayName; + this.Handle = $"@{profile.Handle}"; + this.BannerUrl = profile.Banner; + this.FollowerCount = (int)profile.FollowersCount; + this.FollowingCount = (int)profile.FollowsCount; + this.PostCount = (int)profile.PostsCount; + this.Bio = profile.Description?.Trim(); } protected override void OnLoadingChanged(bool value) diff --git a/UniSky/ViewModels/Text/RichTextViewModel.cs b/UniSky/ViewModels/Text/RichTextViewModel.cs new file mode 100644 index 0000000..ca0d9e2 --- /dev/null +++ b/UniSky/ViewModels/Text/RichTextViewModel.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using FishyFlip.Lexicon; +using FishyFlip.Lexicon.App.Bsky.Richtext; +using FishyFlip.Models; + +namespace UniSky.ViewModels.Text; + +public class FacetInline +{ + public string Text { get; } + public IList Properties { get; } + + public FacetInline(string text, FacetProperty[] facetTypes = null) + { + Text = text; + Properties = facetTypes ?? []; + } +} + +public abstract class FacetProperty { } + +public class LinkProperty(string url) : FacetProperty +{ + public string Url { get; } = url; +} + +public class MentionProperty(ATDid did) : FacetProperty +{ + public ATDid Did { get; } = did; +} + +public class TagProperty(string tag) : FacetProperty +{ + public string Tag { get; } = tag; +} + + +// TODO: BluemojiFacet +// FormattedFacet + +public class RichTextViewModel +{ + private static readonly Encoding UTF8NoBom + = new UTF8Encoding(false); + + private readonly string text; + private readonly byte[] utf8Text; + private readonly Facet[] facets; + + public IList Facets { get; } + + public RichTextViewModel(string text, IList facets) + { + this.text = text; + this.utf8Text = UTF8NoBom.GetBytes(text); + this.facets = [.. facets]; + + this.Facets = ParseFacets(); + } + + private IList ParseFacets() + { + var facetInlines = new List(facets.Length + 5); + + var idx = 0L; + var utf8Span = new Span(utf8Text); + for (int i = 0; i < facets.Length; i++) + { + var facet = facets[i]; + var start = facet.Index!.ByteStart.Value; + var end = facet.Index!.ByteEnd.Value; + + // we have some leading text + if (idx < start) + { + facetInlines.Add(new FacetInline(GetString(utf8Span, idx, start - 1))); + } + + var str = GetString(utf8Span, start, end - 1); + var facetTypes = facet.Features + .Select(s => s switch + { + Link l => new LinkProperty(l.Uri!), + Tag t => new TagProperty(t.TagValue!), + Mention m => new MentionProperty(m.Did!), + _ => (FacetProperty)null + }) + .Where(f => f is not null) + .ToArray(); + + facetInlines.Add(new FacetInline(str, facetTypes)); + + idx = end; + } + + if (idx < utf8Span.Length) + { + facetInlines.Add(new FacetInline(GetString(utf8Span, idx, utf8Span.Length - 1))); + } + + return facetInlines; + } + + private unsafe string GetString(Span utf8Text, long start, long end) + { + var span = utf8Text.Slice((int)start, (int)(end - start + 1)); + fixed (byte* ptr = span) + { + return UTF8NoBom.GetString(ptr, span.Length); + } + } +}