Compare commits
7 Commits
6134dea044
...
ebed49237c
| Author | SHA1 | Date | |
|---|---|---|---|
| ebed49237c | |||
| dc2b22eaa5 | |||
| 2bb612ee07 | |||
| dc855ab8a0 | |||
| f976bdc2b7 | |||
| 1cf3361e59 | |||
| ada441300a |
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
*
|
||||
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
||||
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
||||
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
||||
* the ratio is exact regardless of content width — a column's content can
|
||||
* never stretch its slot.
|
||||
* Implements yazi's `mgr.ratio` contract: three columns grow at
|
||||
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
|
||||
* so every list tab renders an identical, layout-stable shell. Columns use
|
||||
* `flexBasis={0}` so the ratio is exact regardless of content width — a
|
||||
* column's content can never stretch its slot.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
||||
* KEEPS its 20% slot when blank (never collapses to width 0).
|
||||
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||
* header row: the CURRENT column's title renders top-left in the
|
||||
* parent's slot (the panes above current/preview were removed).
|
||||
@@ -184,7 +184,7 @@ export function PaneRow(props: PaneRowProps) {
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/5) — previous-depth list; title row top-left ───────── */}
|
||||
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
|
||||
<Pane
|
||||
grow={PANE_RATIO.parent}
|
||||
label={currentLabel}
|
||||
@@ -200,7 +200,7 @@ export function PaneRow(props: PaneRowProps) {
|
||||
border={["left", "right"]}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (2/5) — hovered-item detail; no border, no header ────── */}
|
||||
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<Pane
|
||||
grow={PANE_RATIO.preview}
|
||||
|
||||
@@ -239,10 +239,14 @@ export function Shell() {
|
||||
|
||||
// ── Now-playing marquee ────────────────────────────────────────────────────
|
||||
// The now-playing segment takes the full remaining status-bar width and
|
||||
// marquee-scrolls on a 300ms timer when its text overflows; when it fits
|
||||
// (or the bar is too narrow to show anything) it renders statically.
|
||||
// marquee-scrolls when its text overflows; when it fits (or the bar is too
|
||||
// narrow to show anything) it renders statically. Each pass scrolls at
|
||||
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
||||
// before scrolling again.
|
||||
const dims = useTerminalDimensions();
|
||||
const GAP = 3;
|
||||
const SCROLL_STEP_MS = 150;
|
||||
const SCROLL_HOLD_MS = 10_000;
|
||||
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||
const leftFixed = () =>
|
||||
modeLabel().length +
|
||||
@@ -270,10 +274,29 @@ export function Shell() {
|
||||
setScrollOffset(0);
|
||||
if (!text || avail <= 0 || text.length <= avail) return;
|
||||
const cycle = text.length + GAP - avail;
|
||||
const id = setInterval(() => {
|
||||
setScrollOffset((o) => (o + 1) % cycle);
|
||||
}, 150);
|
||||
onCleanup(() => clearInterval(id));
|
||||
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
||||
// then hold again before the next pass.
|
||||
let holdId: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollId: ReturnType<typeof setInterval> | null = null;
|
||||
const startHold = () => {
|
||||
setScrollOffset(0);
|
||||
holdId = setTimeout(() => {
|
||||
scrollId = setInterval(() => {
|
||||
const next = scrollOffset() + 1;
|
||||
if (next >= cycle) {
|
||||
clearInterval(scrollId!);
|
||||
startHold();
|
||||
} else {
|
||||
setScrollOffset(next);
|
||||
}
|
||||
}, SCROLL_STEP_MS);
|
||||
}, SCROLL_HOLD_MS);
|
||||
};
|
||||
startHold();
|
||||
onCleanup(() => {
|
||||
if (holdId) clearTimeout(holdId);
|
||||
if (scrollId) clearInterval(scrollId);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,101 +24,100 @@ import { TABS } from "@/utils/navigation";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
|
||||
const TAB_ICON: Record<TABS, string> = {
|
||||
[TABS.FEED]: NF_ICONS.feed,
|
||||
[TABS.MYSHOWS]: NF_ICONS.shows,
|
||||
[TABS.DISCOVER]: NF_ICONS.discover,
|
||||
[TABS.SEARCH]: NF_ICONS.search,
|
||||
[TABS.PLAYER]: NF_ICONS.player,
|
||||
[TABS.SETTINGS]: NF_ICONS.settings,
|
||||
[TABS.FEED]: NF_ICONS.feed,
|
||||
[TABS.MYSHOWS]: NF_ICONS.shows,
|
||||
[TABS.DISCOVER]: NF_ICONS.discover,
|
||||
[TABS.SEARCH]: NF_ICONS.search,
|
||||
[TABS.PLAYER]: NF_ICONS.player,
|
||||
[TABS.SETTINGS]: NF_ICONS.settings,
|
||||
};
|
||||
|
||||
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||
const TAB_ORDER = Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
(v): v is TABS => typeof v === "number",
|
||||
) as TABS[];
|
||||
|
||||
export function TabListPane(props: { muted?: boolean }) {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const cursor = () => nav.tabCursor();
|
||||
const activeTab = () => nav.activeTab();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
const cursor = () => nav.tabCursor();
|
||||
const activeTab = () => nav.activeTab();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
|
||||
// Same focus-bg / focus-fg contract every other pane uses.
|
||||
const focusBg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.primary
|
||||
: t === cursor()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.surface
|
||||
: t === cursor()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
// Same focus-bg / focus-fg contract every other pane uses.
|
||||
const focusBg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.primary
|
||||
: t === cursor()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.surface
|
||||
: t === cursor()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab;
|
||||
const isActive = () => activeTab() === tab;
|
||||
// The active tab is only accented in the Up/parent position — when this
|
||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||
const labelFg = () =>
|
||||
isCursor()
|
||||
? focusFg(tab)
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
const ref = useScrollIntoView(isCursor);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
onMouseDown={() => {
|
||||
// Click = hover + open, the yazi "open" of the row
|
||||
// (switches to the tab and enters its content), the same
|
||||
// as l/Enter. Restores mouse support the tab-strip
|
||||
// refactor dropped.
|
||||
nav.setTabCursor(tab);
|
||||
nav.activateTabCursor();
|
||||
}}
|
||||
>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(tab)} paddingRight={1}>
|
||||
{TAB_ICON[tab]}
|
||||
</text>
|
||||
)}
|
||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab;
|
||||
const isActive = () => activeTab() === tab;
|
||||
// The active tab is only accented in the Up/parent position — when this
|
||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||
const labelFg = () =>
|
||||
isCursor()
|
||||
? focusFg(tab)
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
const ref = useScrollIntoView(isCursor);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
onMouseDown={() => {
|
||||
// Click = hover + open, the yazi "open" of the row
|
||||
// (switches to the tab and enters its content), the same
|
||||
// as l/Enter. Restores mouse support the tab-strip
|
||||
// refactor dropped.
|
||||
nav.setTabCursor(tab);
|
||||
nav.activateTabCursor();
|
||||
}}
|
||||
>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(tab)} paddingRight={1}>
|
||||
{TAB_ICON[tab]}
|
||||
</text>
|
||||
)}
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* parent | current | preview
|
||||
*
|
||||
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
|
||||
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
|
||||
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||
* nav model — which column is focused and where its list cursor lives. The
|
||||
* parent/preview columns are always derived, never focused.
|
||||
|
||||
@@ -267,31 +267,55 @@ function FeedPage() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(index(), fi(), isActive())}
|
||||
>
|
||||
{index() === fi() ? marker() : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(index(), fi(), isActive())}
|
||||
>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi() ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||
{/* podcast name on its own row — readable at a glance; the
|
||||
50% current pane fits it in full for typical names, and
|
||||
truncate keeps the row one line tall either way */}
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={index() === fi() ? theme.surface : theme.textSecondary}
|
||||
>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === fi() ? theme.surface : theme.info}
|
||||
>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -362,26 +362,41 @@ export function MyShowsPage() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(index(), lf(), isActive())}
|
||||
>
|
||||
{index() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(index(), lf(), isActive())}
|
||||
>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.info}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === lf() ? theme.surface : theme.info}
|
||||
>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === lf() ? theme.surface : muted()}
|
||||
>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text fg={downloadColor(ep.id)}>
|
||||
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -112,9 +112,13 @@ export function PlayerPage() {
|
||||
/>
|
||||
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
{"P play/pause N next B prev < > seek h back"}
|
||||
</text>
|
||||
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||
* transform HTML-escapes static string children (`<` → `<`),
|
||||
* which opentui renders verbatim; content bypasses that. */}
|
||||
<text
|
||||
fg={muted()}
|
||||
content={"P play/pause N next B prev < > seek h back"}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ function AddSourceForm() {
|
||||
width={25}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -120,6 +121,7 @@ function AddSourceForm() {
|
||||
width={35}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
|
||||
@@ -43,6 +43,17 @@ function saveSources(sources: PodcastSource[]): void {
|
||||
saveSourcesToFile(sources);
|
||||
}
|
||||
|
||||
/** True when two episode lists hold the same episodes (id-set equality,
|
||||
* order-insensitive). Refreshes compare fetched content against this so an
|
||||
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
|
||||
* "updated" sort — instead of reordering the list on every background
|
||||
* refresh. */
|
||||
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const ids = new Set(a.map((e) => e.id));
|
||||
return b.every((e) => ids.has(e.id));
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
@@ -249,6 +260,26 @@ function createFeedStore() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
||||
* only when the content actually changed (see sameEpisodes). Returns the
|
||||
* ORIGINAL array reference when nothing changed so callers skip
|
||||
* persistence entirely — a refresh that fetched identical episodes must
|
||||
* not re-sort the "updated" view. */
|
||||
const applyRefreshedEpisodes = (
|
||||
prev: Feed[],
|
||||
feedId: string,
|
||||
episodes: Episode[],
|
||||
): Feed[] => {
|
||||
let changed = false;
|
||||
const updated = prev.map((f) => {
|
||||
if (f.id !== feedId) return f;
|
||||
if (sameEpisodes(f.episodes, episodes)) return f;
|
||||
changed = true;
|
||||
return { ...f, episodes, lastUpdated: new Date() };
|
||||
});
|
||||
return changed ? updated : prev;
|
||||
};
|
||||
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
const refreshFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
@@ -259,10 +290,8 @@ function createFeedStore() {
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||
if (updated !== prev) saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -271,14 +300,35 @@ function createFeedStore() {
|
||||
runAutoDownload();
|
||||
};
|
||||
|
||||
/** Refresh all feeds */
|
||||
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
|
||||
* atomic update. Per-feed incremental setFeeds re-sorted the list once
|
||||
* per completion (each refresh bumped lastUpdated and the "updated" sort
|
||||
* re-ran), which showed up as the list order flapping until the batch
|
||||
* finished. */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
currentFeeds.map(async (feed) => [
|
||||
feed.id,
|
||||
await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feed.id,
|
||||
),
|
||||
] as const),
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
let updated = prev;
|
||||
for (const [feedId, episodes] of results) {
|
||||
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
||||
}
|
||||
if (updated !== prev) saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
// Global auto-download: one idempotent pass after the batch.
|
||||
runAutoDownload();
|
||||
} finally {
|
||||
setIsLoadingFeeds(false);
|
||||
}
|
||||
|
||||
@@ -57,13 +57,13 @@ export function rootFrameFor(
|
||||
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||
//
|
||||
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
|
||||
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
|
||||
// give `current` the combined 4/5.
|
||||
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
|
||||
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
|
||||
// give `current` the combined 8/10 (80%).
|
||||
export const PANE_RATIO = {
|
||||
parent: 1,
|
||||
current: 2,
|
||||
preview: 2,
|
||||
parent: 2,
|
||||
current: 5,
|
||||
preview: 3,
|
||||
} as const;
|
||||
|
||||
// Number of *focusable* content panes per tab. The three visible columns
|
||||
|
||||
155
tests/feed-refresh.test.ts
Normal file
155
tests/feed-refresh.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Feed refresh order-stability regression test.
|
||||
*
|
||||
* My Shows / Feed sort by `lastUpdated` ("updated") by default, and every
|
||||
* refresh bumped it unconditionally — so a startup refresh-all re-sorted the
|
||||
* list once per feed as each fetch landed (order flapping until the batch
|
||||
* finished). These tests pin the contract:
|
||||
*
|
||||
* 1. A refresh that fetches identical episodes does NOT bump lastUpdated —
|
||||
* the feed object is untouched, so the list cannot reorder.
|
||||
* 2. A refresh that fetches genuinely new episodes DOES bump lastUpdated.
|
||||
* 3. refreshAllFeeds applies one atomic update: unchanged feeds keep their
|
||||
* order and timestamps after a full refresh.
|
||||
*
|
||||
* The clock is mocked (fake timers) so the "did lastUpdated advance?" checks
|
||||
* are deterministic — no real sleeps that would race under load.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-refresh-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
let feedAId = "";
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
(ep, i) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Test Show</title>
|
||||
<description>Regression test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
const makePodcast = (feedUrl: string): Podcast => ({
|
||||
id: feedUrl,
|
||||
title: "Test Show",
|
||||
description: "Regression test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refresh with identical episodes does not bump lastUpdated", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [
|
||||
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||
];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/show-a.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
feedAId = feed!.id;
|
||||
|
||||
const before = store.getFeed(feedAId)!;
|
||||
const beforeUpdated = before.lastUpdated.getTime();
|
||||
|
||||
// Advance the (mocked) clock, then refresh with identical content.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(feedAId);
|
||||
|
||||
const after = store.getFeed(feedAId)!;
|
||||
expect(after).toBe(before); // same object: no update applied at all
|
||||
expect(after.lastUpdated.getTime()).toBe(beforeUpdated);
|
||||
expect(after.episodes.length).toBe(3);
|
||||
});
|
||||
|
||||
test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes.push({ title: "Ep 0 (new)", date: "2026-08-04T00:00:00Z" });
|
||||
|
||||
const before = store.getFeed(feedAId)!.lastUpdated.getTime();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(feedAId);
|
||||
|
||||
const after = store.getFeed(feedAId)!;
|
||||
expect(after.lastUpdated.getTime()).toBeGreaterThan(before);
|
||||
expect(after.episodes.length).toBe(4);
|
||||
});
|
||||
|
||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||
const store = useFeedStore();
|
||||
// Feed B: distinct URL, identical served content, so refreshing it is a
|
||||
// no-op too.
|
||||
const feedBUrl = `http://127.0.0.1:${server!.port}/show-b.xml`;
|
||||
const feedB = await store.addFeed(makePodcast(feedBUrl), "test-source");
|
||||
expect(feedB).not.toBeNull();
|
||||
const feedBId = feedB!.id;
|
||||
|
||||
const orderBefore = store.getFilteredFeeds().map((f) => f.id);
|
||||
const tsBefore: Record<string, number> = {};
|
||||
for (const id of [feedAId, feedBId]) {
|
||||
tsBefore[id] = store.getFeed(id)!.lastUpdated.getTime();
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshAllFeeds();
|
||||
|
||||
expect(store.getFilteredFeeds().map((f) => f.id)).toEqual(orderBefore);
|
||||
for (const id of [feedAId, feedBId]) {
|
||||
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
||||
}
|
||||
});
|
||||
207
tests/feed-row-wrap.test.tsx
Normal file
207
tests/feed-row-wrap.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Episode-row height regression — Feed rows must stay exactly 3 lines tall:
|
||||
* title, podcast name, meta (date + duration + markers). Every line carries
|
||||
* `wrapMode="none"` + `truncate` on flexible text and `flexShrink={0}` on
|
||||
* fixed-width cells so Yoga can never shrink a text below its content width
|
||||
* and wrap it — a wrapped row grows to 4+ lines and every entry below
|
||||
* shifts its starting position while scrolling (the original bug).
|
||||
*
|
||||
* Rendered at 70 columns so the 35-col current pane is narrow enough to
|
||||
* force truncation on the long title; at the default 100-col/50-col pane
|
||||
* the same rows show everything in full.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterAll } from "bun:test";
|
||||
import type { JSX } from "solid-js";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { PaneRow } from "../src/components/PaneRow";
|
||||
|
||||
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||
|
||||
function frameText(spans: Frame): string[] {
|
||||
return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||
}
|
||||
|
||||
// The exact row shape FeedPage renders: title, podcast name, meta. Static
|
||||
// text, no store hooks — pure layout probe.
|
||||
const FixedRow = () => (
|
||||
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0}>❯</text>
|
||||
<text wrapMode="none" truncate>
|
||||
#674 - Scott Payne
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text wrapMode="none" truncate>
|
||||
This Past Weekend w/ Theo Von
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text flexShrink={0}>Aug 10, 2026</text>
|
||||
<text flexShrink={0}>3h 50m</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
// A long title proves the truncate guard: without it the title wraps to
|
||||
// multiple lines and the row grows past 3 lines.
|
||||
const LongTitleFixed = () => (
|
||||
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0}>❯</text>
|
||||
<text wrapMode="none" truncate>
|
||||
Out of Whiskey and Reaching for the Rotgut (Members Only #338)
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text wrapMode="none" truncate>
|
||||
This Past Weekend w/ Theo Von
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text flexShrink={0}>Aug 10, 2026</text>
|
||||
<text flexShrink={0}>3h 50m</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
|
||||
// wraps at the shrunken width and the row grows.
|
||||
const NaiveRow = () => (
|
||||
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>❯</text>
|
||||
<text>Out of Whiskey and Reaching for the Rotgut (Members Only #338)</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text>This Past Weekend w/ Theo Von</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text>Aug 10, 2026</text>
|
||||
<text>3h 50m</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
async function renderCurrent(
|
||||
row: () => JSX.Element,
|
||||
width = 70,
|
||||
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<PaneRow
|
||||
parent={null}
|
||||
current={row}
|
||||
preview={null}
|
||||
currentLabel="List"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width, height: 10, useThread: false },
|
||||
);
|
||||
// ThemeProvider mounts children only once the theme resolves; poll for
|
||||
// the header row so the captured frame is a mounted PaneRow.
|
||||
let lines: string[] | null = null;
|
||||
for (let i = 0; i < 40 && !lines; i++) {
|
||||
await setup.renderOnce();
|
||||
const frame = setup.captureSpans() as unknown as Frame;
|
||||
const ls = frameText(frame);
|
||||
if (ls.some((l) => l.includes("List"))) lines = ls;
|
||||
else await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!lines) throw new Error("PaneRow did not render before timeout");
|
||||
return {
|
||||
lines,
|
||||
destroy: async () => {
|
||||
setup.renderer.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cleanups: (() => void | Promise<void>)[] = [];
|
||||
afterAll(async () => {
|
||||
for (const c of cleanups) {
|
||||
try {
|
||||
await c();
|
||||
} catch {
|
||||
// renderer already torn down — ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("episode row height in the current pane (70-wide → 35-col pane)", () => {
|
||||
test("fixed row: title, podcast name and meta each render on exactly one line", async () => {
|
||||
const { lines, destroy } = await renderCurrent(() => <FixedRow />);
|
||||
cleanups.push(destroy);
|
||||
|
||||
// Podcast name is fully visible on its own line — the usability the
|
||||
// dedicated row exists for.
|
||||
expect(lines.filter((l) => l.includes("This Past Weekend w/ Theo Von"))).toHaveLength(1);
|
||||
// Title and meta each on a single frame line (a wrapped date would
|
||||
// split "Aug 10, 2026" across lines).
|
||||
expect(lines.filter((l) => l.includes("Scott Payne"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("Aug 10, 2026"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("3h 50m"))).toHaveLength(1);
|
||||
// The row occupies exactly the 3 content lines below the header.
|
||||
const content = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Scott Payne") ||
|
||||
l.includes("This Past Weekend") ||
|
||||
l.includes("Aug 10,"),
|
||||
);
|
||||
expect(content).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("long title stays on one line (truncated), keeping the row at 3 lines", async () => {
|
||||
const { lines, destroy } = await renderCurrent(() => <LongTitleFixed />);
|
||||
cleanups.push(destroy);
|
||||
|
||||
// Truncated: the title head appears on exactly one line and the full
|
||||
// title never appears on any line (middle-ellipsis clips it).
|
||||
expect(lines.filter((l) => l.includes("Out of Whiske"))).toHaveLength(1);
|
||||
expect(
|
||||
lines.some((l) =>
|
||||
l.includes(
|
||||
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)",
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
// Podcast name and meta still each on one line — row total 3.
|
||||
expect(lines.filter((l) => l.includes("This Past Weekend"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("Aug 10,"))).toHaveLength(1);
|
||||
const content = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiske") ||
|
||||
l.includes("This Past Weekend") ||
|
||||
l.includes("Aug 10,"),
|
||||
);
|
||||
expect(content).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("naive row (pre-fix props) wraps the long title — the regression the test guards", async () => {
|
||||
const { lines, destroy } = await renderCurrent(() => <NaiveRow />);
|
||||
cleanups.push(destroy);
|
||||
|
||||
// The title's wrapped fragments span 3 frame lines instead of 1.
|
||||
const titleFragments = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiske") ||
|
||||
l.includes("for the Rotgut") ||
|
||||
l.includes("#338)"),
|
||||
);
|
||||
expect(titleFragments).toHaveLength(3);
|
||||
// Row total: 3 title lines + podcast + meta = 5 content lines.
|
||||
const content = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiske") ||
|
||||
l.includes("for the Rotgut") ||
|
||||
l.includes("#338)") ||
|
||||
l.includes("This Past Weekend") ||
|
||||
l.includes("Aug 10,"),
|
||||
);
|
||||
expect(content).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* PaneRow tests — the 1:2:2 parent|current|preview layout primitive.
|
||||
* PaneRow tests — the 2:5:3 (20/50/30) parent|current|preview layout primitive.
|
||||
*
|
||||
* Verified through the opentui test renderer's captured frames (the same
|
||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||
* observable as rendered column widths.
|
||||
* observable in rendered output, not in unit-testable state.
|
||||
*
|
||||
* • Unit: three columns render at 1:2:2 (e.g. 20/40/40 of 100) even when the
|
||||
* • Unit: three columns render at 2:5:3 (e.g. 20/50/30 of 100) even when the
|
||||
* parent and preview children are null, and the blank parent keeps its
|
||||
* slot with a muted placeholder.
|
||||
* • Integration: the current pane renders muted left/right border edges
|
||||
@@ -119,9 +119,9 @@ afterAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Unit: three columns at 1:2:2 regardless of null children ───────────────
|
||||
// ── Unit: three columns at 2:5:3 regardless of null children ───────────────
|
||||
describe("PaneRow layout", () => {
|
||||
test("renders three columns at 1:2:2 even with null parent/preview", async () => {
|
||||
test("renders three columns at 2:5:3 even with null parent/preview", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
@@ -132,15 +132,15 @@ describe("PaneRow layout", () => {
|
||||
const widths = columnWidths(spans);
|
||||
expect(widths).toHaveLength(3);
|
||||
const [p, c, v] = widths;
|
||||
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5).
|
||||
// 100-wide row splits as 20 / 50 / 30 (2 : 5 : 3 of 10).
|
||||
expect(p).toBe(20);
|
||||
expect(c).toBe(40);
|
||||
expect(v).toBe(40);
|
||||
// Exact 1:2:2 proportion (within 1 col rounding).
|
||||
expect(c).toBeGreaterThanOrEqual(p * 2 - 1);
|
||||
expect(c).toBeLessThanOrEqual(p * 2 + 1);
|
||||
expect(v).toBeGreaterThanOrEqual(p * 2 - 1);
|
||||
expect(v).toBeLessThanOrEqual(p * 2 + 1);
|
||||
expect(c).toBe(50);
|
||||
expect(v).toBe(30);
|
||||
// Exact 2:5:3 proportion (within 1 col rounding).
|
||||
expect(c).toBeGreaterThanOrEqual(Math.round(p * 2.5) - 1);
|
||||
expect(c).toBeLessThanOrEqual(Math.round(p * 2.5) + 1);
|
||||
expect(v).toBeGreaterThanOrEqual(Math.round(p * 1.5) - 1);
|
||||
expect(v).toBeLessThanOrEqual(Math.round(p * 1.5) + 1);
|
||||
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
|
||||
expect(p).toBeGreaterThan(4);
|
||||
const body = spans.lines
|
||||
@@ -150,7 +150,7 @@ describe("PaneRow layout", () => {
|
||||
expect(body).toContain("ITEM");
|
||||
});
|
||||
|
||||
test("keeps the 1/5 parent slot across widths (ratio stable)", async () => {
|
||||
test("keeps the 20% parent slot across widths (ratio stable)", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>x</text>,
|
||||
@@ -159,9 +159,9 @@ describe("PaneRow layout", () => {
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
const [p, c, v] = columnWidths(spans);
|
||||
expect(p).toBe(14); // 70 → 14 / 28 / 28
|
||||
expect(c).toBe(28);
|
||||
expect(v).toBe(28);
|
||||
expect(p).toBe(14); // 70 → 14 / 35 / 21
|
||||
expect(c).toBe(35);
|
||||
expect(v).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,9 +181,9 @@ describe("PaneRow current-pane borders", () => {
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
|
||||
// 100-wide row splits as 20 / 40 / 40: the current pane's edges sit at
|
||||
// columns 20 and 59. No horizontal or corner glyphs — edges only.
|
||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||
// 100-wide row splits as 20 / 50 / 30: the current pane's edges sit at
|
||||
// columns 20 and 69. No horizontal or corner glyphs — edges only.
|
||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("PaneRow current-pane borders", () => {
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
|
||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("PaneRow current-pane borders", () => {
|
||||
focused: () => true,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||
|
||||
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
||||
@@ -218,7 +218,7 @@ describe("PaneRow current-pane borders", () => {
|
||||
focused: () => false,
|
||||
});
|
||||
cleanups.push(destroy2);
|
||||
expect(borderColumns(spans2)).toEqual([20, 59]);
|
||||
expect(borderColumns(spans2)).toEqual([20, 69]);
|
||||
expect(frameText(spans2)).not.toMatch(boxGlyphs);
|
||||
});
|
||||
|
||||
@@ -229,7 +229,7 @@ describe("PaneRow current-pane borders", () => {
|
||||
preview: null,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user