2026-10-05 01:23:18 +02:00
@page "/account/{accountId}"
@attribute [Authorize]
@inherits LocalizableComponentBase
<Title>@(account?.Name ?? Localizer["Profile"])</Title>
@* 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,
2026-10-17 04:02:20 +02:00
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
2026-10-18 08:58:30 +02:00
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. *@
2026-10-05 01:23:18 +02:00
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<div class="flex items-center gap-2">
<DButton Size="DSize.Sm" OnClick="Back" title="@Localizer["Back"]">
<i class="ion-md-arrow-back"></i>
</DButton>
<h1 class="font-bold truncate">@Localizer["Profile"]</h1>
</div>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (account is null)
{
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["This account doesn't exist."]
</p>
}
else
{
2026-10-05 18:39:56 +02:00
@if (account.Moved is { } moved)
{
<p class="flex flex-wrap items-center gap-2 p-3 rounded-xl neo-inset neo-2xs text-sm" data-moved>
<i class="ion-md-swap"></i>
<span>@Localizer["{0} has moved to", account.Name]</span>
<a class="link font-semibold" href="@($"account/{Uri.EscapeDataString(moved.Id)}")">@("@" + moved.Acct)</a>
</p>
}
2026-10-06 23:14:03 +02:00
@* not overflow-hidden: the Lists menu hangs out of it; the header picture takes the card's top corners itself *@
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs" data-account-id="@account.Id">
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center rounded-t-xl" style="@HeaderStyle"></div>
2026-10-05 01:23:18 +02:00
<div class="flex flex-wrap items-end gap-3 px-4 -mt-10">
@* alt="": the name is next to it. *@
<img alt="" class="h-20 w-20 md:h-24 md:w-24 flex-none object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(account.Avatar ?? "imgs/icon-192.png")" />
<div class="flex flex-col min-w-0 flex-1 pb-1">
2026-10-05 17:55:39 +02:00
<b class="text-lg truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
2026-10-05 01:23:18 +02:00
<span class="text-sm opacity-70 truncate">@("@" + FullAcct)</span>
</div>
@if (!IsOwn)
{
<div class="flex flex-wrap gap-2 pb-1">
@if (relationship?.FollowedBy == true)
{
<DBadge Size="DSize.Sm" class="self-center">@Localizer["Follows you"]</DBadge>
}
@if (relationship?.Blocking != true)
{
<DButton Size="DSize.Sm" Color="@(relationship?.Following == true || relationship?.Requested == true ? DColor.Neutral : DColor.Primary)"
Disabled="@(IsDefaultDisabled || isActing || relationship is null)" OnClick="ToggleFollow">
@FollowLabel
</DButton>
}
2026-10-05 02:49:51 +02:00
@if (relationship?.Following == true)
{
2026-10-06 23:14:03 +02:00
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm" SheetOnNarrow="true"
2026-10-05 02:49:51 +02:00
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-64 bg-base-100 neo-raised neo-xs z-20" title="@Localizer["Lists"]">
<Trigger>
<i class="ion-md-list"></i><span>@Localizer["Lists"]</span>
</Trigger>
<ChildContent>
@if (allLists.Count == 0)
{
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
}
@foreach (var list in allLists)
{
<label class="label text-sm" data-list-id="@list.Id">
<input type="checkbox" class="checkbox checkbox-sm" checked="@inLists.Contains(list.Id)" disabled="@(IsDefaultDisabled || isActing)"
@onchange="@((ChangeEventArgs e) => SetListMember(list, e.Value is true))" />
<span class="truncate">@list.Title</span>
</label>
}
<a class="btn btn-xs" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
</ChildContent>
</DDropdown>
}
2026-10-05 01:23:18 +02:00
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
<span>@(relationship?.Muting == true ? Localizer["Unmute"] : Localizer["Mute"])</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
OnClick="@(() => Do(relationship?.Blocking == true ? AccountAction.Unblock : AccountAction.Block))">
<i class="ion-md-remove-circle text-error"></i>
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
</DButton>
2026-10-06 16:47:19 +02:00
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i class="ion-md-flag text-warning"></i>
<span>@Localizer["Report"]</span>
</DButton>
2026-10-05 01:23:18 +02:00
</div>
2026-10-06 16:47:19 +02:00
@if (isReporting)
{
<ReportDialog Account="account" OnClosed="@(() => isReporting = false)" />
}
2026-10-05 01:23:18 +02:00
}
</div>
<div class="flex flex-wrap gap-2 px-4 pt-2">
@if (account.Locked)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-lock"></i> @Localizer["Locked account"]</DBadge>
}
@if (account.Bot)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-cog"></i> @Localizer["Bot"]</DBadge>
}
@if (account.Group)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft"><i class="ion-md-people"></i> @Localizer["Groups"]</DBadge>
}
2026-10-17 05:41:10 +02:00
@if (account.Limited == true)
{
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-limited
title="@Localizer["This server's staff limited it: its posts stay out of public timelines, trends and search."]">
<i class="ion-md-volume-off"></i> @Localizer["Limited"]
</DBadge>
}
2026-10-05 01:23:18 +02:00
</div>
@if (!string.IsNullOrWhiteSpace(account.Note))
{
2026-10-05 17:55:39 +02:00
<div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(account.Note, account.Emojis))</div>
2026-10-05 01:23:18 +02:00
}
@if (account.Fields.Count > 0)
{
<dl class="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 pt-3 text-sm">
@foreach (var field in account.Fields)
{
2026-10-05 17:55:39 +02:00
<dt class="font-semibold truncate">@CustomEmojis.Text(field.Name, account.Emojis)</dt>
<dd class="post-content break-words min-w-0">@((MarkupString)CustomEmojis.Html(field.Value, account.Emojis))</dd>
2026-10-05 01:23:18 +02:00
}
</dl>
}
2026-10-18 08:58:30 +02:00
@if (featuredTags.Count > 0 || IsOwn)
{
<div class="flex flex-wrap items-center gap-2 px-4 pt-3 text-sm" role="group" aria-label="@Localizer["Featured hashtags"]" data-profile-featured-tags>
@foreach (var featured in featuredTags)
{
<button type="button" class="badge badge-soft badge-sm gap-1" title="@FeaturedTitle(featured)" @onclick="() => OpenTag(featured.Name)"
data-profile-featured-tag="@featured.Name">
<span>#@featured.Name</span><span class="opacity-60 tabular-nums">@featured.Posts</span>
</button>
}
@if (IsOwn)
{
<a class="link link-hover text-xs touch-target" href="settings?open=profile" data-profile-featured-tags-edit>
<i class="ion-md-create" aria-hidden="true"></i>
@(featuredTags.Count == 0 ? Localizer["Feature hashtags on this profile"] : Localizer["Edit featured hashtags"])
</a>
}
</div>
}
2026-10-17 04:02:20 +02:00
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
2026-10-05 01:23:18 +02:00
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
2026-10-17 04:02:20 +02:00
@if (IsOwn)
{
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = false" data-profile-following>
<b>@account.FollowingCount</b> @Localizer["following"]
</button>
<button type="button" class="link link-hover touch-target" @onclick="() => showAccounts = true" data-profile-followers>
<b>@account.FollowersCount</b> @Localizer["followers"]
</button>
}
else
{
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
}
2026-10-05 01:23:18 +02:00
</div>
</article>
2026-10-18 08:58:30 +02:00
@if (!IsOwn && relationship is { } related)
{
<section class="flex flex-none flex-col gap-3 p-3 md:p-4 rounded-xl neo-inset neo-2xs" aria-label="@Localizer["Only for this persona"]" data-profile-private>
<p class="flex items-center gap-2 text-xs opacity-80">
<i class="ion-md-lock" aria-hidden="true"></i>
<span>@Localizer["Only this persona sees what is here: {0} is never told.", account.Name]</span>
</p>
@if (related.Following)
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Active="related.Notifying" Disabled="@(IsDefaultDisabled || isActing)" OnClick="ToggleNotify"
aria-pressed="@(related.Notifying ? "true" : "false")" data-profile-notify="@(related.Notifying ? "on" : "off")">
<i class="@(related.Notifying ? "ion-md-notifications" : "ion-md-notifications-off")" aria-hidden="true"></i>
<span>@(related.Notifying ? Localizer["Notified of new posts"] : Localizer["Notify me of new posts"])</span>
</DButton>
<DDropdown IsOpen="isLanguagesOpen" IsOpenChanged="LanguagesOpened" TriggerClass="btn btn-sm" SheetOnNarrow="true"
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-80 max-w-[calc(100vw-2rem)] bg-base-100 neo-raised neo-xs z-20"
title="@Localizer["Languages of its posts in your home feed"]" data-profile-languages="@(string.Join(",", related.WantedLanguages))">
<Trigger>
<i class="ion-md-globe" aria-hidden="true"></i><span class="truncate max-w-48">@LanguagesLabel</span>
</Trigger>
<ChildContent>
<p class="text-xs opacity-70">@Localizer["Only its posts in the languages ticked reach your home feed, and those whose language is unknown. None ticked: every language."]</p>
<div class="grid grid-cols-2 gap-x-2 gap-y-1 max-h-64 overflow-y-auto">
@foreach (var (languageCode, languageName) in LanguageChoices)
{
<DCheckbox Size="DSize.Sm" LabelText="@languageName" Value="@wantedLanguages.Contains(languageCode)"
ValueChanged="on => TickLanguage(languageCode, on)" ValueExpression="() => languageFlag" data-profile-language="@languageCode" />
}
</div>
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="EveryLanguage" data-profile-languages-all>@Localizer["Every language"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="SaveLanguages" data-profile-languages-save>
@Localizer["Save"]
</DButton>
</div>
</ChildContent>
</DDropdown>
<DButton Size="DSize.Sm" Active="related.Endorsed" Disabled="@(IsDefaultDisabled || isActing)" OnClick="ToggleEndorse"
title="@(related.Endorsed ? Localizer["Take {0} out of my recommendations", account.Name] : Localizer["Keep {0} in my recommendations", account.Name])"
aria-pressed="@(related.Endorsed ? "true" : "false")" data-profile-endorse="@(related.Endorsed ? "on" : "off")">
<i class="ion-md-ribbon" aria-hidden="true"></i>
<span>@(related.Endorsed ? Localizer["Recommended"] : Localizer["Recommend"])</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["Your recommendations stay private: only this persona sees them, never on its profile nor anywhere else."]</p>
}
@* the note is saved as it loses focus (focusout bubbles from the textarea), or with Save *@
<div class="flex flex-col gap-1" @onfocusout="SaveNote">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">@Localizer["Private note"]</span>
<DTextarea TValue="string" class="w-full" @bind-Value="noteDraft" Immediate="true" rows="3" autocomplete="off"
maxlength="@AccountService.NoteLength" placeholder="@Localizer["Something to remember about {0}", account.Name]" data-profile-note />
</label>
<div class="flex flex-wrap items-center justify-end gap-2 pointer-coarse:gap-2">
@if (noteSaved && !NoteChanged)
{
<span class="me-auto text-xs text-success" role="status" data-profile-note-saved>
<i class="ion-md-checkmark" aria-hidden="true"></i> @Localizer["Saved."]
</span>
}
<span class="text-xs opacity-70 tabular-nums">@(noteDraft?.Length ?? 0)/@AccountService.NoteLength</span>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSavingNote || !NoteChanged)" OnClick="SaveNote" data-profile-note-save>
@if (isSavingNote)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Save"]</span>
</DButton>
</div>
</div>
</section>
}
2026-10-17 04:02:20 +02:00
@if (IsOwn)
{
@if (showAccounts is { } followers)
{
<AccountListDialog AccountId="@account.Id" Followers="followers" OnClosed="@(() => showAccounts = null)" OnRemoved="FollowerRemoved" />
}
<FollowRequestsPanel HideWhenEmpty="true" ShowHeading="true" />
}
2026-10-05 01:23:18 +02:00
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden">
<DTab><Header><span class="capitalize">@Localizer["posts"]</span></Header></DTab>
<DTab><Header>@Localizer["Posts and replies"]</Header></DTab>
<DTab><Header>@Localizer["Media"]</Header></DTab>
</DTabs>
<div class="flex flex-col gap-4">
2026-10-05 23:04:31 +02:00
@if (tab is ProfileTab.Posts && pinned.Count > 0)
{
<div class="flex flex-col gap-4" data-pinned>
<span class="badge badge-ghost gap-1 self-start"><i aria-hidden="true" class="ion-md-pin"></i>@Localizer["Pinned"]</span>
@foreach (var post in pinned)
{
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
}
</div>
}
@foreach (var post in posts.Where(p => tab is not ProfileTab.Posts || !pinnedKeys.Contains(p.Key)))
2026-10-05 01:23:18 +02:00
{
2026-10-05 03:10:27 +02:00
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
2026-10-05 01:23:18 +02:00
}
@if (posts.Count == 0 && !isLoadingMore)
{
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
}
@if (maxId is not null)
{
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="LoadMore">
@if (isLoadingMore)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@Localizer["Load more"]</span>
</DButton>
}
</div>
}
</div>
</section>
@code {
[Inject] AccountService AccountService { get; set; }
2026-10-06 23:28:22 +02:00
[Inject] ConfirmService Confirm { get; set; }
2026-10-05 02:49:51 +02:00
[Inject] ListService ListService { get; set; }
2026-10-05 01:23:18 +02:00
[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<FeedPost> posts = [];
2026-10-05 23:04:31 +02:00
List<FeedPost> pinned = [];
HashSet<string> pinnedKeys = [];
2026-10-05 01:23:18 +02:00
ProfileTab tab = ProfileTab.Posts;
string maxId;
string loadedId;
string loadedPersonaId;
bool isActing;
bool isLoadingMore;
2026-10-05 02:49:51 +02:00
bool isListMenuOpen;
2026-10-06 16:47:19 +02:00
bool isReporting;
2026-10-17 04:02:20 +02:00
// the persona's own followers (true) or the accounts it follows (false), while that list is open
bool? showAccounts;
2026-10-05 02:49:51 +02:00
List<MastodonList> allLists = [];
HashSet<string> inLists = [];
2026-10-18 08:58:30 +02:00
List<FeaturedTag> featuredTags = [];
bool isLanguagesOpen;
// the languages ticked while the languages menu is open, saved together
HashSet<string> 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];
2026-10-05 01:23:18 +02:00
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);
2026-10-18 08:58:30 +02:00
noteDraft = relationship?.Note;
noteSaved = false;
featuredTags = await AccountService.FeaturedTags(account.Id);
2026-10-05 02:49:51 +02:00
await LoadLists();
2026-10-05 23:04:31 +02:00
pinned = await AccountService.Pinned(account.Id);
pinnedKeys = [.. pinned.Select(p => p.Key)];
2026-10-05 01:23:18 +02:00
posts = [];
maxId = null;
IsLoading = false;
await LoadPage();
}
2026-10-05 02:49:51 +02:00
/// <summary>The persona's lists and which of them hold the account, for an account the persona follows.</summary>
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]);
}
2026-10-05 01:23:18 +02:00
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)
{
2026-10-06 23:28:22 +02:00
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;
2026-10-05 01:23:18 +02:00
isActing = true;
var result = await AccountService.Act(account.Id, action);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
relationship = (Relationship)result.Data ?? relationship;
2026-10-05 02:49:51 +02:00
if (action is AccountAction.Follow or AccountAction.Unfollow)
await LoadLists();
2026-10-05 01:23:18 +02:00
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]
});
}
2026-10-18 08:58:30 +02:00
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<string> 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;
}
2026-10-17 04:02:20 +02:00
void FollowerRemoved(Account follower) => account.FollowersCount = Math.Max(0, account.FollowersCount - 1);
2026-10-05 23:04:31 +02:00
void Remove(FeedPost post)
{
posts.RemoveAll(p => p.Key == post.Key);
pinned.RemoveAll(p => p.Key == post.Key);
}
2026-10-05 01:23:18 +02:00
async Task OpenTag(string tagName)
{
var filter = await LocalStorage.GetItemAsync<FeedFilter>(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");
}