fix(feed): a channel filter's chips name their channels from the first paint
After a reload the chips (filter panel and the row above the feed) read as raw UC… ids until the subscription list landed. The names seen last time are now kept per account (useChannelNames) and name the chips at once; the list then replaces the copy, so a renamed or dropped channel heals. A channel never named on this browser draws a placeholder bone while the list loads; a failed list still falls back to the id.
This commit is contained in:
@@ -41,6 +41,9 @@ export const LS = {
|
||||
channelCardSize: "siftlode.channelCardSize",
|
||||
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
|
||||
channelsTable: "siftlode.channelsTable",
|
||||
// Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
|
||||
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
||||
channelNames: "siftlode.channelNames",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
||||
// statsTab below.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
||||
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
||||
import { useChannelNames } from "./useChannelNames";
|
||||
import { parseSort } from "./feedSort";
|
||||
import { useTags } from "./useTags";
|
||||
|
||||
@@ -9,6 +8,8 @@ import { useTags } from "./useTags";
|
||||
export interface ActiveFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
|
||||
pending?: boolean;
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
@@ -49,12 +50,7 @@ export function useActiveFilters(
|
||||
): ActiveFilter[] {
|
||||
const { t } = useTranslation();
|
||||
const tagsQuery = useTags();
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: api.channels,
|
||||
enabled: filters.channelIds.length > 0,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const channelNames = useChannelNames(filters.channelIds.length > 0);
|
||||
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
@@ -113,9 +109,11 @@ export function useActiveFilters(
|
||||
for (const id of filters.channelIds) {
|
||||
out.push({
|
||||
key: `channel:${id}`,
|
||||
// Fall back to the id, not a generic label: several chips reading "This channel" while
|
||||
// the name cache loads would be indistinguishable, and each ✕ drops a different one.
|
||||
label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
|
||||
// Fall back to the id, not a generic label: several chips reading "This channel" would be
|
||||
// indistinguishable, and each ✕ drops a different one. While the list loads, a channel this
|
||||
// browser has never named draws a placeholder instead (the id stays its accessible name).
|
||||
label: channelNames.names[id] ?? id,
|
||||
pending: channelNames.names[id] === undefined && channelNames.pending,
|
||||
remove: () =>
|
||||
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type ManagedChannel } from "./api";
|
||||
import { LS, readAccount, writeAccount } from "./storage";
|
||||
|
||||
type Names = Record<string, string>;
|
||||
|
||||
const namesOf = (channels: ManagedChannel[]): Names =>
|
||||
Object.fromEntries(channels.map((c) => [c.id, c.title ?? c.id]));
|
||||
|
||||
const readNames = (): Names => {
|
||||
const raw = readAccount<unknown>(LS.channelNames, {});
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Names) : {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Channel id → name, for the chips that name a channel filter (the filter panel's picker and the chip
|
||||
* row above the feed). The names come from the subscription list, which a reload has not fetched yet:
|
||||
* for a moment the chips read as raw UC… ids (the owner, 2026-10-03). The names seen last time are
|
||||
* kept per account and name the chips until the list lands; the list then wins and replaces the copy,
|
||||
* so a renamed or dropped channel heals on the next load.
|
||||
*
|
||||
* `pending` is true while the list loads: a channel this browser has never named then has no name to
|
||||
* show, and the chip draws a placeholder rather than its id. A failed list leaves the id (the chips
|
||||
* stay told apart, and each ✕ drops a different one).
|
||||
*/
|
||||
export function useChannelNames(enabled = true) {
|
||||
const query = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: api.channels,
|
||||
enabled,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const [remembered] = useState(readNames);
|
||||
useEffect(() => {
|
||||
if (query.data) writeAccount(LS.channelNames, namesOf(query.data));
|
||||
}, [query.data]);
|
||||
return {
|
||||
query,
|
||||
names: query.data ? namesOf(query.data) : remembered,
|
||||
pending: enabled && query.isPending,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user