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,
|
|
|
|
|
never from its server directly. *@
|
|
|
|
|
<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-05 01:23:18 +02:00
|
|
|
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs overflow-hidden" data-account-id="@account.Id">
|
|
|
|
|
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center" 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">
|
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)
|
|
|
|
|
{
|
|
|
|
|
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm"
|
|
|
|
|
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>
|
|
|
|
|
}
|
|
|
|
|
</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>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-wrap gap-4 px-4 py-3 text-sm tabular-nums">
|
|
|
|
|
<span><b>@account.StatusesCount</b> @Localizer["posts"]</span>
|
|
|
|
|
<span><b>@account.FollowingCount</b> @Localizer["following"]</span>
|
|
|
|
|
<span><b>@account.FollowersCount</b> @Localizer["followers"]</span>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
<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-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-05 02:49:51 +02:00
|
|
|
List<MastodonList> allLists = [];
|
|
|
|
|
HashSet<string> inLists = [];
|
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-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)
|
|
|
|
|
{
|
|
|
|
|
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-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");
|
|
|
|
|
}
|