A profile shows the hashtags an account features as chips opening their feed; under another account's header is what the persona keeps of it for itself alone: for one it follows, a bell (follow with notify), the languages of its posts that reach the home feed (follow with languages[]) and Recommend (endorsements, private here), and for any account a private note saved on blur or with Save. Settings edits the persona's featured hashtags (added by name or from those used lately, 10 at most) and its reading preferences (/clientapi/avatar/private/preferences), which CascadingState keeps per persona and the card applies to sensitive media and content warnings. A hashtag's tab features it on the profile. Explore has Who to follow (/api/v2/suggestions, dismissed for good after asking); Administration has Staff picks. The Direct tab follows the direct stream's conversation events; status notifications show the post. The follows CSV carries notify and languages. Verified: Release build, 0 errors (the 3 warnings were there before). Not run against PrivaPub or in the pasture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
633 lines
25 KiB
Plaintext
633 lines
25 KiB
Plaintext
@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,
|
|
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. *@
|
|
<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
|
|
{
|
|
@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>
|
|
}
|
|
@* 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>
|
|
<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">
|
|
<b class="text-lg truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
|
<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>
|
|
}
|
|
@if (relationship?.Following == true)
|
|
{
|
|
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm" SheetOnNarrow="true"
|
|
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>
|
|
}
|
|
<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>
|
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
|
<i class="ion-md-flag text-warning"></i>
|
|
<span>@Localizer["Report"]</span>
|
|
</DButton>
|
|
</div>
|
|
@if (isReporting)
|
|
{
|
|
<ReportDialog Account="account" OnClosed="@(() => isReporting = false)" />
|
|
}
|
|
}
|
|
</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>
|
|
}
|
|
@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>
|
|
}
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(account.Note))
|
|
{
|
|
<div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(account.Note, account.Emojis))</div>
|
|
}
|
|
|
|
@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)
|
|
{
|
|
<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>
|
|
}
|
|
</dl>
|
|
}
|
|
|
|
@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>
|
|
}
|
|
|
|
<div class="flex flex-wrap items-center gap-4 px-4 py-3 text-sm tabular-nums">
|
|
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
|
@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>
|
|
}
|
|
</div>
|
|
</article>
|
|
|
|
@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>
|
|
}
|
|
|
|
@if (IsOwn)
|
|
{
|
|
@if (showAccounts is { } followers)
|
|
{
|
|
<AccountListDialog AccountId="@account.Id" Followers="followers" OnClosed="@(() => showAccounts = null)" OnRemoved="FollowerRemoved" />
|
|
}
|
|
<FollowRequestsPanel HideWhenEmpty="true" ShowHeading="true" />
|
|
}
|
|
|
|
<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">
|
|
@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)))
|
|
{
|
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
|
|
}
|
|
@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; }
|
|
[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<FeedPost> posts = [];
|
|
List<FeedPost> pinned = [];
|
|
HashSet<string> 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<MastodonList> allLists = [];
|
|
HashSet<string> inLists = [];
|
|
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];
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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]);
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
|
|
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<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");
|
|
}
|