Below xl every feed tab but the selected one was a lone icon with no name, for a screen reader or a finger. DTab takes an AccessibleName, which DTabs renders as the tab's label and tooltip, and FeedTabs names each tab; on a phone every tab shows its name too. The strip is marked as one that scrolls sideways (data-scroll-x), says it holds the feeds, and on a phone fades at the edge where more tabs wait (edge-fade-x, a scroll-driven animation where the browser has one); the selected tab is brought into view when it changes (window.revealActive). Explore's tabs get the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
214 lines
8.5 KiB
Plaintext
214 lines
8.5 KiB
Plaintext
@inherits LocalizableComponentBase
|
|
|
|
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
|
|
persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being
|
|
read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was
|
|
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
@* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
|
|
tabs wait; from md only the selected tab shows its name until xl *@
|
|
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
|
|
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
|
|
aria-label="@Localizer["Feeds"]">
|
|
@foreach (var type in Types)
|
|
{
|
|
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
|
|
<Header>
|
|
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
|
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
|
|
@if (type is TimelineType.Notifications && UnreadNotifications > 0)
|
|
{
|
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
|
|
}
|
|
else if (type is TimelineType.Direct && UnreadConversations > 0)
|
|
{
|
|
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadConversations]" data-unread-conversations>@UnreadConversations</DBadge>
|
|
}
|
|
</Header>
|
|
</DTab>
|
|
}
|
|
</DTabs>
|
|
</div>
|
|
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
|
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
|
|
</NerdTip>
|
|
<DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
|
|
<i class="ion-md-planet text-base"></i>
|
|
</DButton>
|
|
@* a phone held sideways has no room for the banner: the globe opens full screen *@
|
|
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
|
|
data-globe-full>
|
|
<i class="ion-md-planet text-base"></i>
|
|
</DButton>
|
|
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
|
|
<i class="ion-md-create text-base"></i>
|
|
<span class="hidden sm:inline">@Localizer["Post"]</span>
|
|
</DButton>
|
|
</div>
|
|
|
|
@if (Filter.TimelineType is TimelineType.Groups)
|
|
{
|
|
@if (Groups.Count == 0)
|
|
{
|
|
<p class="text-xs opacity-70">@Localizer["This persona is in no community or circle yet."]</p>
|
|
}
|
|
else
|
|
{
|
|
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.GroupId" ValueChanged="SelectGroup" ValueExpression="() => Filter.GroupId">
|
|
<Prefix><i class="@(SelectedGroup?.IsCommunity == false ? "ion-md-lock" : "ion-md-contacts")"></i></Prefix>
|
|
<ChildContent>
|
|
@foreach (var group in Groups)
|
|
{
|
|
<option value="@group.Id">@(group.IsCommunity ? "◎" : "◉") @(group.Name ?? group.UserName)</option>
|
|
}
|
|
</ChildContent>
|
|
</DSelect>
|
|
}
|
|
}
|
|
else if (Filter.TimelineType is TimelineType.Lists)
|
|
{
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
@if (Lists.Count == 0)
|
|
{
|
|
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
|
|
}
|
|
else
|
|
{
|
|
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.ListId" ValueChanged="SelectList" ValueExpression="() => Filter.ListId">
|
|
<Prefix><i class="ion-md-list"></i></Prefix>
|
|
<ChildContent>
|
|
@foreach (var list in Lists)
|
|
{
|
|
<option value="@list.Id">@list.Title</option>
|
|
}
|
|
</ChildContent>
|
|
</DSelect>
|
|
}
|
|
<a class="btn btn-sm" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
|
|
</div>
|
|
}
|
|
else if (Filter.TimelineType is TimelineType.Tag)
|
|
{
|
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
<i class="ion-md-pricetag"></i><b>#@Filter.Tag</b>
|
|
@if (tag is not null)
|
|
{
|
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", tag.WeekUses, tag.TodayAccounts]</span>
|
|
<DButton Size="DSize.Xs" Color="@(tag.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isFollowing)"
|
|
OnClick="ToggleTagFollow" data-tag-follow>
|
|
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
|
|
</DButton>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
|
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
|
[Parameter] public List<MastodonList> Lists { get; set; } = [];
|
|
/// <summary>Groups of notifications newer than the persona's marker; a badge on the Notifications tab.</summary>
|
|
[Parameter] public int UnreadNotifications { get; set; }
|
|
/// <summary>How many of the newest direct conversations hold a message the persona has not read.</summary>
|
|
[Parameter] public int UnreadConversations { get; set; }
|
|
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
|
[Parameter] public bool IsComposing { get; set; }
|
|
[Parameter] public EventCallback OnCompose { get; set; }
|
|
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
|
|
[Parameter] public EventCallback OnGlobeFull { get; set; }
|
|
/// <summary>Raised after Filter changed (tab, group or globe).</summary>
|
|
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
|
|
|
|
static readonly TimelineType[] Always =
|
|
[
|
|
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
|
|
TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks, TimelineType.Lists
|
|
];
|
|
|
|
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
|
|
|
int ActiveIndex => Types.IndexOf(Filter.TimelineType);
|
|
|
|
ElementReference strip;
|
|
int revealed = -1;
|
|
|
|
protected override void OnAfterRender(bool firstRender)
|
|
{
|
|
if (ActiveIndex == revealed)
|
|
return;
|
|
revealed = ActiveIndex;
|
|
CascadingState.Status.RevealActive(strip);
|
|
}
|
|
|
|
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
|
|
|
[Inject] DiscoveryService DiscoveryService { get; set; }
|
|
|
|
Tag tag;
|
|
string loadedTag;
|
|
bool isFollowing;
|
|
|
|
protected override void OnInitialized() => IsLoading = false;
|
|
|
|
// the hashtag being read: its week and whether the persona follows it, read again when it changes
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
var wanted = Filter.TimelineType is TimelineType.Tag ? Filter.Tag : null;
|
|
if (wanted == loadedTag)
|
|
return;
|
|
loadedTag = wanted;
|
|
tag = wanted is { Length: > 0 } ? await DiscoveryService.Tag(wanted) : null;
|
|
}
|
|
|
|
async Task ToggleTagFollow()
|
|
{
|
|
isFollowing = true;
|
|
var result = await DiscoveryService.FollowTag(tag.Name, tag.Following != true);
|
|
isFollowing = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
tag = (Tag)result.Data;
|
|
Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]);
|
|
}
|
|
|
|
string TabName(TimelineType type) => type switch
|
|
{
|
|
TimelineType.Federation => Localizer["Federated"],
|
|
TimelineType.Tag => "#" + Filter.Tag,
|
|
_ => Localizer[type.ToString()]
|
|
};
|
|
|
|
async Task Select(int index)
|
|
{
|
|
Filter.TimelineType = Types[index];
|
|
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
|
Filter.GroupId = Groups.FirstOrDefault()?.Id;
|
|
if (Filter.TimelineType is TimelineType.Lists && Lists.All(l => l.Id != Filter.ListId))
|
|
Filter.ListId = Lists.FirstOrDefault()?.Id;
|
|
await OnChanged.InvokeAsync(Filter);
|
|
}
|
|
|
|
async Task SelectList(string listId)
|
|
{
|
|
Filter.ListId = listId;
|
|
await OnChanged.InvokeAsync(Filter);
|
|
}
|
|
|
|
async Task SelectGroup(string groupId)
|
|
{
|
|
Filter.GroupId = groupId;
|
|
await OnChanged.InvokeAsync(Filter);
|
|
}
|
|
|
|
async Task ToggleGlobe()
|
|
{
|
|
Filter.ShowGlobe = !Filter.ShowGlobe;
|
|
await OnChanged.InvokeAsync(Filter);
|
|
}
|
|
}
|