Files
decePubClient/Components/Feed/FeedTabs.razor
T

213 lines
8.5 KiB
Plaintext
Raw Normal View History

@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">
2026-10-06 23:31:07 +02:00
@* 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)
{
2026-10-06 23:31:07 +02:00
<DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
<Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
2026-10-06 23:31:07 +02:00
<span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
2026-10-05 04:35:23 +02:00
@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; } = [];
2026-10-05 04:35:23 +02:00
/// <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);
2026-10-06 23:31:07 +02:00
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);
}
}