@page "/account/{accountId}" @attribute [Authorize] @inherits LocalizableComponentBase @(account?.Name ?? Localizer["Profile"]) @* An account as the current persona sees it (/api/v1/accounts/{id}): its header, bio and fields, its counts, what it is to the persona (following, followed by, requested, blocked, muted) with the actions that change it, and its posts, page by page, as posts only, posts and replies, or media. A remote account's profile comes from what PrivaPub knows, never from its server directly. On the persona's own profile its counts open its followers and the accounts it follows (AccountListDialog: PrivaPub lists them to the persona alone, so another profile keeps plain counts), and the follow requests waiting for it show above its posts. The hashtags an account features are chips opening their feed (the persona's own link to Settings, where they are edited). Under another account's header is what the persona keeps of it for itself alone, which the account is never told: for one it follows, whether it is told of its new posts (a `status` notification), which languages of its posts reach the home feed, and whether it is in the persona's recommendations (endorsements, private here); for any account, a private note, saved when it loses focus or with Save. *@

@Localizer["Profile"]

@if (IsLoading) {

@Localizer["loading"]

} else if (account is null) {

@Localizer["This account doesn't exist."]

} else { @if (account.Moved is { } moved) {

@Localizer["{0} has moved to", account.Name] @("@" + moved.Acct)

} @* not overflow-hidden: the Lists menu hangs out of it; the header picture takes the card's top corners itself *@
@* alt="": the name is next to it. *@
@CustomEmojis.Text(account.Name, account.Emojis) @("@" + FullAcct)
@if (!IsOwn) {
@if (relationship?.FollowedBy == true) { @Localizer["Follows you"] } @if (relationship?.Blocking != true) { @FollowLabel } @if (relationship?.Following == true) { @Localizer["Lists"] @if (allLists.Count == 0) {

@Localizer["This persona has no list yet."]

} @foreach (var list in allLists) { } @Localizer["Manage lists"]
} @(relationship?.Muting == true ? Localizer["Unmute"] : Localizer["Mute"]) @(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"]) @Localizer["Report"]
@if (isReporting) { } }
@if (account.Locked) { @Localizer["Locked account"] } @if (account.Bot) { @Localizer["Bot"] } @if (account.Group) { @Localizer["Groups"] } @if (account.Limited == true) { @Localizer["Limited"] }
@if (!string.IsNullOrWhiteSpace(account.Note)) {
@((MarkupString)CustomEmojis.Html(account.Note, account.Emojis))
} @if (account.Fields.Count > 0) {
@foreach (var field in account.Fields) {
@CustomEmojis.Text(field.Name, account.Emojis)
@((MarkupString)CustomEmojis.Html(field.Value, account.Emojis))
}
} @if (featuredTags.Count > 0 || IsOwn) {
@foreach (var featured in featuredTags) { } @if (IsOwn) { @(featuredTags.Count == 0 ? Localizer["Feature hashtags on this profile"] : Localizer["Edit featured hashtags"]) }
}
@account.StatusesCount @Localizer["posts"] @if (IsOwn) { } else { @account.FollowingCount @Localizer["following"] @account.FollowersCount @Localizer["followers"] }
@if (!IsOwn && relationship is { } related) {

@Localizer["Only this persona sees what is here: {0} is never told.", account.Name]

@if (related.Following) {
@(related.Notifying ? Localizer["Notified of new posts"] : Localizer["Notify me of new posts"]) @LanguagesLabel

@Localizer["Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language."]

@foreach (var (languageCode, languageName) in LanguageChoices) { }
@Localizer["Every language"] @Localizer["Save"]
@(related.Endorsed ? Localizer["Recommended"] : Localizer["Recommend"])

@Localizer["Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else."]

} @* the note is saved as it loses focus (focusout bubbles from the textarea), or with Save *@
@if (noteSaved && !NoteChanged) { @Localizer["Saved."] } @(noteDraft?.Length ?? 0)/@AccountService.NoteLength @if (isSavingNote) { } @Localizer["Save"]
} @if (IsOwn) { @if (showAccounts is { } followers) { } }
@Localizer["posts"]
@Localizer["Posts and replies"]
@Localizer["Media"]
@if (tab is ProfileTab.Posts && pinned.Count > 0) {
@Localizer["Pinned"] @foreach (var post in pinned) { }
} @foreach (var post in posts.Where(p => tab is not ProfileTab.Posts || !pinnedKeys.Contains(p.Key))) { } @if (posts.Count == 0 && !isLoadingMore) {

@Localizer["Empty"]

} @if (maxId is not null) { @if (isLoadingMore) { } @Localizer["Load more"] }
}
@code { [Inject] AccountService AccountService { get; set; } [Inject] ConfirmService Confirm { get; set; } [Inject] ListService ListService { get; set; } [Inject] InstanceService InstanceService { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; } [Inject] IJSRuntime JS { get; set; } [Parameter] public string AccountId { get; set; } Account account; Relationship relationship; List posts = []; List pinned = []; HashSet pinnedKeys = []; ProfileTab tab = ProfileTab.Posts; string maxId; string loadedId; string loadedPersonaId; bool isActing; bool isLoadingMore; bool isListMenuOpen; bool isReporting; // the persona's own followers (true) or the accounts it follows (false), while that list is open bool? showAccounts; List allLists = []; HashSet inLists = []; List featuredTags = []; bool isLanguagesOpen; // the languages ticked while the languages menu is open, saved together HashSet wantedLanguages = []; // DCheckbox's ValueExpression for the languages, which are bound one by one (never read) bool languageFlag = false; // the private note as it is being written; relationship.Note is what PrivaPub keeps string noteDraft; bool isSavingNote; bool noteSaved; bool NoteChanged => (noteDraft?.Trim() ?? string.Empty) != (relationship?.Note ?? string.Empty); string LanguagesLabel => relationship.WantedLanguages.Count == 0 ? Localizer["Every language"] : string.Join(", ", relationship.WantedLanguages.Select(LanguageName)); // the languages a post can be marked with, and any other the follow keeps IEnumerable<(string Code, string Name)> LanguageChoices => Languages.All.Concat(relationship.WantedLanguages.Where(code => Languages.All.All(l => l.Code != code)).Select(code => (code, code))); static string LanguageName(string code) => Languages.All.Where(l => l.Code == code).Select(l => l.Name).FirstOrDefault() ?? code; string FeaturedTitle(FeaturedTag featured) => featured.LastStatusDay is { } last ? Localizer["{0} posts with it, the last on {1}", featured.Posts, last.ToString(SUtility.dateTimeFormat)] : Localizer["{0} posts with it", featured.Posts]; bool IsOwn => account is not null && account.Id == CascadingState.PersonaId; string FullAcct => account.Acct.Contains('@') ? account.Acct : $"{account.Acct}@{account.Host}"; string HeaderStyle => string.IsNullOrEmpty(account?.Header) ? null : $"background-image:url('{account.Header}')"; string FollowLabel => relationship switch { { Following: true } => Localizer["Unfollow"], { Requested: true } => Localizer["Cancel the request"], _ => Localizer["Follow"] }; protected override async Task OnParametersSetAsync() { if (AccountId == loadedId && CascadingState.PersonaId == loadedPersonaId) return; loadedId = AccountId; loadedPersonaId = CascadingState.PersonaId; if (CascadingState.IsOffline) { WhenOnline(LoadData); StartOnlinePolling(); IsLoading = false; return; } await LoadData(); } async Task LoadData() { IsLoading = true; account = await AccountService.Get(AccountId); if (account is null) { IsLoading = false; return; } relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id); noteDraft = relationship?.Note; noteSaved = false; featuredTags = await AccountService.FeaturedTags(account.Id); await LoadLists(); pinned = await AccountService.Pinned(account.Id); pinnedKeys = [.. pinned.Select(p => p.Key)]; posts = []; maxId = null; IsLoading = false; await LoadPage(); } /// The persona's lists and which of them hold the account, for an account the persona follows. async Task LoadLists() { if (relationship?.Following != true) { allLists = []; inLists = []; return; } allLists = await ListService.All(); inLists = [.. (await ListService.Of(account.Id)).Select(l => l.Id)]; } async Task SetListMember(MastodonList list, bool member) { isActing = true; var result = await ListService.SetMember(list.Id, account.Id, member); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } if (member) inLists.Add(list.Id); else inLists.Remove(list.Id); Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, list.Title] : Localizer["{0} removed from {1}.", account.Name, list.Title]); } async Task LoadPage() { isLoadingMore = true; var result = await AccountService.Statuses(account.Id, tab, maxId); isLoadingMore = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } var page = (FeedPage)result.Data; var known = posts.Select(p => p.Key).ToHashSet(); posts.AddRange(page.Posts.Where(p => !known.Contains(p.Key))); maxId = page.MaxId; await InstanceService.Resolve(page.Posts.Select(p => p.Host).Append(InstanceService.SelfHost)); InstanceService.Locate(page.Posts); } async Task LoadMore() => await LoadPage(); async Task SelectTab(int index) { tab = (ProfileTab)index; posts = []; maxId = null; await LoadPage(); } async Task ToggleFollow() => await Do(relationship is { Following: true } or { Requested: true } ? AccountAction.Unfollow : AccountAction.Follow); async Task Do(AccountAction action) { var name = account.Name ?? account.Acct; if (action is AccountAction.Block or AccountAction.Mute && !await Confirm.Ask(action == AccountAction.Block ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null, Localizer[action == AccountAction.Block ? "Block" : "Mute"], action == AccountAction.Block)) return; isActing = true; var result = await AccountService.Act(account.Id, action); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } relationship = (Relationship)result.Data ?? relationship; if (action is AccountAction.Follow or AccountAction.Unfollow) await LoadLists(); Toast.AddResponseSuccess(action switch { AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name], AccountAction.Follow => Localizer["Following {0}.", account.Name], AccountAction.Unfollow => Localizer["{0} unfollowed.", account.Name], AccountAction.Block => Localizer["{0} blocked.", account.Name], AccountAction.Unblock => Localizer["{0} unblocked.", account.Name], AccountAction.Mute => Localizer["{0} muted.", account.Name], _ => Localizer["{0} unmuted.", account.Name] }); } async Task ToggleNotify() { isActing = true; var result = await AccountService.SetNotify(account.Id, relationship.ShowingReblogs, !relationship.Notifying); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } relationship = (Relationship)result.Data ?? relationship; Toast.AddResponseSuccess(relationship.Notifying ? Localizer["You'll be told when {0} posts.", account.Name] : Localizer["No longer told when {0} posts.", account.Name]); } // the menu opens on the languages the follow keeps void LanguagesOpened(bool open) { isLanguagesOpen = open; if (open) wantedLanguages = [.. relationship.WantedLanguages]; } void TickLanguage(string code, bool on) { if (on) wantedLanguages.Add(code); else wantedLanguages.Remove(code); } Task SaveLanguages() => SetLanguages(wantedLanguages); Task EveryLanguage() => SetLanguages([]); async Task SetLanguages(IReadOnlyCollection languages) { isActing = true; var result = await AccountService.SetLanguages(account.Id, relationship.ShowingReblogs, languages); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } relationship = (Relationship)result.Data ?? relationship; isLanguagesOpen = false; Toast.AddResponseSuccess(relationship.WantedLanguages.Count == 0 ? Localizer["Every post of {0} reaches your home feed, whatever its language.", account.Name] : Localizer["Only the posts of {0} in {1} reach your home feed.", account.Name, LanguagesLabel]); } async Task ToggleEndorse() { isActing = true; var result = await AccountService.Endorse(account.Id, !relationship.Endorsed); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } relationship = (Relationship)result.Data ?? relationship; Toast.AddResponseSuccess(relationship.Endorsed ? Localizer["{0} is in your recommendations, which only you see.", account.Name] : Localizer["{0} is no longer in your recommendations.", account.Name]); } // as the note loses focus too: only a note changed is sent, one at a time async Task SaveNote() { if (isSavingNote || relationship is null || !NoteChanged) return; var sent = noteDraft; isSavingNote = true; var result = await AccountService.SetNote(account.Id, sent); isSavingNote = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } relationship = (Relationship)result.Data ?? relationship; // what was written meanwhile stays as it is if (noteDraft == sent) noteDraft = relationship.Note; noteSaved = true; } void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1); void Remove(FeedPost post) { posts.RemoveAll(p => p.Key == post.Key); pinned.RemoveAll(p => p.Key == post.Key); } async Task OpenTag(string tagName) { var filter = await LocalStorage.GetItemAsync(nameof(FeedFilter)) ?? new(); filter.Tag = tagName; filter.TimelineType = TimelineType.Tag; await LocalStorage.SetItemAsync(nameof(FeedFilter), filter); Navigation.NavigateTo(""); } async Task Back() => await JS.InvokeVoidAsync("history.back"); }