On a phone the globe is a banner above the feed; a tap on it now opens it full screen over everything, with a close button (Escape closes it too), the focus moving to that button and back, and the feed underneath kept as it was but out of reach (inert). The camera refits the new shape. Held sideways, a phone (a window at most 30rem tall, the new short: variant) has no room for the banner: the banner and its toggle give way to a button that opens the globe full screen. isMobileMedia, which picks the first visit's default, agrees with Tailwind's md now (it was off by one) and counts a phone held sideways. GlobePanel tells its state (data-globe-state). Phone flows: the globe opens full screen and closes, and a sideways phone keeps a feed at least 180px tall with no sideways scroll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
200 lines
7.9 KiB
Plaintext
200 lines
7.9 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">
|
|
<div class="flex-1 min-w-0 overflow-x-auto">
|
|
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
|
@foreach (var type in Types)
|
|
{
|
|
<DTab Class="gap-1 whitespace-nowrap">
|
|
<Header>
|
|
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
|
|
<span class="@(type == Filter.TimelineType ? null : "hidden 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);
|
|
|
|
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);
|
|
}
|
|
}
|