mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
fix(desktop): surface channel history load failures (#7013)
## Summary A failed initial channel-history request no longer appears as an authoritative empty channel. The timeline now shows an announced error with a Retry action, while cached messages remain visible when a later refresh fails; successful empty channels continue to use their normal intro state. ### Related issue None found. ### Testing - Full desktop unit suite (`pnpm test`) - Desktop TypeScript check (`pnpm exec tsc --noEmit`) - Biome checks for changed files - Repository file-size ratchet - Full pre-push desktop checks and tests - Desktop app launched successfully against local Postgres and Redis for manual testing No screenshot is included because the new UI is only shown after a terminal relay-history failure; the regression test pins the error/empty/list precedence directly. --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getChannelDetail } from "@/features/channels/lib/channelDescription";
|
||||
import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
|
||||
@@ -106,3 +107,19 @@ export function mentionsKnownAgent(
|
||||
knownAgentPubkeys.has(pubkey.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
export function selectThreadComposerBotTypingPubkeys(
|
||||
entries: TypingIndicatorEntry[],
|
||||
threadHeadId: string | null,
|
||||
) {
|
||||
if (!threadHeadId) return [];
|
||||
return entries
|
||||
.filter((entry) => entry.threadHeadId === threadHeadId)
|
||||
.map((entry) => entry.pubkey)
|
||||
.filter(
|
||||
(pubkey, index, all) =>
|
||||
all.findIndex(
|
||||
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
|
||||
) === index,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner";
|
||||
import {
|
||||
mentionsKnownAgent,
|
||||
selectThreadComposerBotTypingPubkeys,
|
||||
shouldPrioritizeIdleAuxiliary,
|
||||
shouldUseFocusIdleDrawer,
|
||||
} from "@/features/channels/ui/ChannelPane.helpers";
|
||||
@@ -103,7 +104,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
isJoining = false,
|
||||
isSinglePanelView = false,
|
||||
isSending,
|
||||
isTimelineError = false,
|
||||
isTimelineLoading,
|
||||
onRetryTimeline,
|
||||
entranceMessageId = null,
|
||||
onEntranceMessageComplete,
|
||||
welcomeKickoffStage = null,
|
||||
@@ -334,18 +337,11 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
const hasCardMintActivity = useCardMintJobs().length > 0;
|
||||
const hasComposerBottomActivity =
|
||||
hasComposerBotActivity || hasTypingActivity || hasCardMintActivity;
|
||||
const threadComposerBotTypingPubkeys = React.useMemo(() => {
|
||||
if (!openThreadHeadId) return [];
|
||||
return botTypingEntries
|
||||
.filter((entry) => entry.threadHeadId === openThreadHeadId)
|
||||
.map((entry) => entry.pubkey)
|
||||
.filter(
|
||||
(pubkey, index, all) =>
|
||||
all.findIndex(
|
||||
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
|
||||
) === index,
|
||||
);
|
||||
}, [botTypingEntries, openThreadHeadId]);
|
||||
const threadComposerBotTypingPubkeys = React.useMemo(
|
||||
() =>
|
||||
selectThreadComposerBotTypingPubkeys(botTypingEntries, openThreadHeadId),
|
||||
[botTypingEntries, openThreadHeadId],
|
||||
);
|
||||
const hasThreadComposerBotActivity =
|
||||
threadComposerBotTypingPubkeys.length > 0;
|
||||
const directMessageIntro = React.useMemo(
|
||||
@@ -662,7 +658,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
: "No messages yet"
|
||||
: "No channel selected"
|
||||
}
|
||||
isError={isTimelineError}
|
||||
isLoading={isHuddleTranscript ? false : isTimelineLoading}
|
||||
onRetry={onRetryTimeline}
|
||||
entranceMessageId={entranceMessageId}
|
||||
onEntranceMessageComplete={onEntranceMessageComplete}
|
||||
mainEntries={mainTimelineEntries}
|
||||
|
||||
@@ -59,7 +59,10 @@ export type ChannelPaneProps = {
|
||||
isJoining?: boolean;
|
||||
isSinglePanelView?: boolean;
|
||||
isSending: boolean;
|
||||
/** Terminal channel-history failure. Cached messages remain visible when present. */
|
||||
isTimelineError?: boolean;
|
||||
isTimelineLoading: boolean;
|
||||
onRetryTimeline?: () => void;
|
||||
/** Newly-created message that should receive the one-shot conversation arrival motion. */
|
||||
entranceMessageId?: string | null;
|
||||
onEntranceMessageComplete?: (messageId: string) => void;
|
||||
|
||||
@@ -50,10 +50,7 @@ import {
|
||||
isThreadReply,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache";
|
||||
import {
|
||||
resolveTimelineLoadingLatch,
|
||||
selectTimelineLoadingState,
|
||||
} from "@/features/messages/lib/timelineLoadingState";
|
||||
import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState";
|
||||
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
|
||||
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
|
||||
import { useThreadReplies } from "@/features/messages/useThreadReplies";
|
||||
@@ -616,27 +613,21 @@ export function ChannelScreen({
|
||||
setThreadScrollTargetId,
|
||||
});
|
||||
const settledChannelIdRef = React.useRef<string | null>(null);
|
||||
const hasSettledThisChannel =
|
||||
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
|
||||
const timelineLoadingNow =
|
||||
activeChannel !== null &&
|
||||
activeChannel.channelType !== "forum" &&
|
||||
selectTimelineLoadingState(
|
||||
const { settledChannelId, isLoading: isTimelineLoading } =
|
||||
resolveTimelineQueryLoadingState(
|
||||
settledChannelIdRef.current,
|
||||
activeChannelId,
|
||||
{
|
||||
isEnabled:
|
||||
activeChannel !== null && activeChannel.channelType !== "forum",
|
||||
isPending: messagesQuery.isPending,
|
||||
isFetching: messagesQuery.isFetching,
|
||||
isPlaceholderData: messagesQuery.isPlaceholderData,
|
||||
dataLength: messagesQuery.data?.length ?? null,
|
||||
isError: messagesQuery.isError,
|
||||
},
|
||||
hasSettledThisChannel ||
|
||||
(activeChannelId !== null &&
|
||||
hasPersistedHydratedChannel(queryClient, activeChannelId)),
|
||||
);
|
||||
const { settledChannelId, isLoading: isTimelineLoading } =
|
||||
resolveTimelineLoadingLatch(
|
||||
settledChannelIdRef.current,
|
||||
activeChannelId,
|
||||
timelineLoadingNow,
|
||||
activeChannelId !== null &&
|
||||
hasPersistedHydratedChannel(queryClient, activeChannelId),
|
||||
);
|
||||
settledChannelIdRef.current = settledChannelId;
|
||||
const { welcomeKickoffStage, welcomeKickoffSettingUp } =
|
||||
@@ -890,8 +881,8 @@ export function ChannelScreen({
|
||||
isFollowingThread={isNotifiedForEffectiveThread}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading}
|
||||
onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages}
|
||||
threadSummaries={threadSummaries}
|
||||
huddleThreadRepliesError={huddleThreadRepliesError}
|
||||
onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
|
||||
|
||||
@@ -416,3 +416,74 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
test("test_subscription_refresh_preserves_cold_history_error", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const channelId = "cold-failure";
|
||||
const queryKey = channelMessagesKey(channelId);
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
throw new Error("history unavailable");
|
||||
},
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
|
||||
try {
|
||||
await observer.refetch();
|
||||
assert.equal(observer.getCurrentResult().status, "error");
|
||||
assert.equal(observer.getCurrentResult().data, undefined);
|
||||
|
||||
await assert.rejects(
|
||||
refreshChannelWindowMessages(client, channelId),
|
||||
/history unavailable/,
|
||||
);
|
||||
assert.equal(observer.getCurrentResult().status, "error");
|
||||
assert.equal(observer.getCurrentResult().data, undefined);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
test("test_refresh_failure_retains_cached_rows_and_success_clears_error", async () => {
|
||||
const harness = createHarness();
|
||||
let shouldFail = true;
|
||||
const refreshed = event("refreshed", 110);
|
||||
const observer = new QueryObserver(harness.client, {
|
||||
queryKey: harness.messagesKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (shouldFail) {
|
||||
throw new Error("history unavailable");
|
||||
}
|
||||
const previousMessages =
|
||||
harness.client.getQueryData(harness.messagesKey) ?? [];
|
||||
return reconcileFetchedChannelWindow(
|
||||
harness.client,
|
||||
harness.channelId,
|
||||
wirePage([refreshed, event("initial", 100)]),
|
||||
previousMessages,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
refreshChannelWindowMessages(harness.client, harness.channelId),
|
||||
/history unavailable/,
|
||||
);
|
||||
assert.equal(observer.getCurrentResult().status, "error");
|
||||
assert.deepEqual(contents(harness), ["initial"]);
|
||||
|
||||
shouldFail = false;
|
||||
await refreshChannelWindowMessages(harness.client, harness.channelId);
|
||||
assert.equal(observer.getCurrentResult().status, "success");
|
||||
assert.deepEqual(contents(harness), ["initial", "refreshed"]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function refreshChannelWindowMessages(
|
||||
}
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey, exact: true, refetchType: "active" },
|
||||
{ cancelRefetch: !seeded },
|
||||
{ cancelRefetch: !seeded, throwOnError: true },
|
||||
);
|
||||
projectChannelWindowMessages(queryClient, channelId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
|
||||
import { selectTimelineLoadingState } from "./timelineLoadingState.ts";
|
||||
import {
|
||||
resolveTimelineLoadingLatch,
|
||||
resolveTimelineQueryLoadingState,
|
||||
selectTimelineLoadingState,
|
||||
} from "./timelineLoadingState.ts";
|
||||
|
||||
const settled = {
|
||||
isPending: false,
|
||||
@@ -10,6 +15,27 @@ const settled = {
|
||||
dataLength: null,
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function resolveQueryLoading(result, settledChannelId = null) {
|
||||
return resolveTimelineQueryLoadingState(settledChannelId, "chan-a", {
|
||||
isEnabled: true,
|
||||
isPending: result.isPending,
|
||||
isFetching: result.isFetching,
|
||||
isPlaceholderData: result.isPlaceholderData,
|
||||
dataLength: result.data?.length ?? null,
|
||||
isError: result.isError,
|
||||
});
|
||||
}
|
||||
|
||||
test("pending first fetch with no cache is loading", () => {
|
||||
assert.equal(
|
||||
selectTimelineLoadingState({ ...settled, isPending: true }),
|
||||
@@ -120,8 +146,6 @@ test("settled channel with rows mid-refetch is not loading", () => {
|
||||
);
|
||||
});
|
||||
|
||||
import { resolveTimelineLoadingLatch } from "./timelineLoadingState.ts";
|
||||
|
||||
test("latch: loading on first entry to a channel", () => {
|
||||
const r = resolveTimelineLoadingLatch(null, "chan-a", true);
|
||||
assert.equal(r.isLoading, true);
|
||||
@@ -158,3 +182,80 @@ test("latch: no active channel passes loadingNow through untouched", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("query wiring: cold error retry stays loading until successful empty result", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const first = deferred();
|
||||
const retry = deferred();
|
||||
let attempt = 0;
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey: ["messages", "chan-a"],
|
||||
queryFn: () => [first.promise, retry.promise][attempt++],
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
|
||||
try {
|
||||
first.reject(new Error("history unavailable"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
let loading = resolveQueryLoading(observer.getCurrentResult());
|
||||
assert.equal(observer.getCurrentResult().status, "error");
|
||||
assert.deepEqual(loading, { settledChannelId: null, isLoading: false });
|
||||
|
||||
const retryResult = observer.refetch();
|
||||
loading = resolveQueryLoading(observer.getCurrentResult());
|
||||
assert.equal(observer.getCurrentResult().status, "pending");
|
||||
assert.deepEqual(loading, { settledChannelId: null, isLoading: true });
|
||||
|
||||
retry.resolve([]);
|
||||
await retryResult;
|
||||
loading = resolveQueryLoading(observer.getCurrentResult());
|
||||
assert.equal(observer.getCurrentResult().status, "success");
|
||||
assert.deepEqual(loading, {
|
||||
settledChannelId: "chan-a",
|
||||
isLoading: false,
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
test("query wiring: repeated cold retry failure never settles as empty", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const first = deferred();
|
||||
const retry = deferred();
|
||||
let attempt = 0;
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey: ["messages", "chan-a"],
|
||||
queryFn: () => [first.promise, retry.promise][attempt++],
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
|
||||
try {
|
||||
first.reject(new Error("history unavailable"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), {
|
||||
settledChannelId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const retryResult = observer.refetch();
|
||||
assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), {
|
||||
settledChannelId: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
retry.reject(new Error("still unavailable"));
|
||||
await retryResult;
|
||||
assert.equal(observer.getCurrentResult().status, "error");
|
||||
assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), {
|
||||
settledChannelId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,11 @@ export type TimelineQueryStatus = {
|
||||
dataLength: number | null;
|
||||
};
|
||||
|
||||
export type TimelineQueryLoadingStatus = TimelineQueryStatus & {
|
||||
isEnabled: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export function selectTimelineLoadingState(
|
||||
status: TimelineQueryStatus,
|
||||
hasSettled = true,
|
||||
@@ -50,6 +55,7 @@ export function resolveTimelineLoadingLatch(
|
||||
settledChannelId: string | null,
|
||||
activeChannelId: string | null,
|
||||
loadingNow: boolean,
|
||||
canSettle = true,
|
||||
): { settledChannelId: string | null; isLoading: boolean } {
|
||||
if (activeChannelId === null) {
|
||||
return { settledChannelId, isLoading: loadingNow };
|
||||
@@ -58,9 +64,37 @@ export function resolveTimelineLoadingLatch(
|
||||
// Already settled for this channel — stay loaded through refetch blips.
|
||||
return { settledChannelId, isLoading: false };
|
||||
}
|
||||
if (!loadingNow) {
|
||||
if (!loadingNow && canSettle) {
|
||||
// First settle for this channel; latch it.
|
||||
return { settledChannelId: activeChannelId, isLoading: false };
|
||||
}
|
||||
return { settledChannelId, isLoading: true };
|
||||
return { settledChannelId, isLoading: loadingNow };
|
||||
}
|
||||
|
||||
/**
|
||||
* Production coordinator from the messages query state to the channel loading
|
||||
* latch. Keeping the error guard here prevents a cold terminal failure from
|
||||
* being recorded as an authoritative empty result before Retry starts.
|
||||
*/
|
||||
export function resolveTimelineQueryLoadingState(
|
||||
settledChannelId: string | null,
|
||||
activeChannelId: string | null,
|
||||
status: TimelineQueryLoadingStatus,
|
||||
hasPersistedHydratedChannel = false,
|
||||
): { settledChannelId: string | null; isLoading: boolean } {
|
||||
const hasSettledThisChannel =
|
||||
activeChannelId !== null && settledChannelId === activeChannelId;
|
||||
const loadingNow =
|
||||
status.isEnabled &&
|
||||
selectTimelineLoadingState(
|
||||
status,
|
||||
hasSettledThisChannel || hasPersistedHydratedChannel,
|
||||
);
|
||||
|
||||
return resolveTimelineLoadingLatch(
|
||||
settledChannelId,
|
||||
activeChannelId,
|
||||
loadingNow,
|
||||
!status.isError,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -590,6 +590,30 @@ test("timeline-body-surface: empty only when live and deferred rows are empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-body-surface: terminal history failure never paints false-empty", () => {
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 0,
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
liveCount: 0,
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("timeline-body-surface: cached rows stay visible after a refetch failure", () => {
|
||||
assert.equal(
|
||||
selectTimelineBodySurface({
|
||||
deferredCount: 2,
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
liveCount: 2,
|
||||
}),
|
||||
"list",
|
||||
);
|
||||
});
|
||||
|
||||
test("deferred-snapshot: stale when channel ids diverge during channel switch", () => {
|
||||
assert.equal(
|
||||
isDeferredTimelineSnapshotStale({
|
||||
|
||||
@@ -181,16 +181,18 @@ export function selectDeferredListRenderState(
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export type TimelineBodySurface = "skeleton" | "empty" | "list";
|
||||
export type TimelineBodySurface = "skeleton" | "error" | "empty" | "list";
|
||||
|
||||
export function selectTimelineBodySurface({
|
||||
deferredCount,
|
||||
preserveSettledEmptyIntro = false,
|
||||
isError = false,
|
||||
isLoading,
|
||||
liveCount,
|
||||
}: {
|
||||
deferredCount: number;
|
||||
preserveSettledEmptyIntro?: boolean;
|
||||
isError?: boolean;
|
||||
isLoading: boolean;
|
||||
liveCount: number;
|
||||
}): TimelineBodySurface {
|
||||
@@ -199,6 +201,12 @@ export function selectTimelineBodySurface({
|
||||
}
|
||||
|
||||
const renderState = selectDeferredListRenderState(deferredCount, liveCount);
|
||||
if (renderState === "list") {
|
||||
return "list";
|
||||
}
|
||||
if (isError && liveCount === 0) {
|
||||
return "error";
|
||||
}
|
||||
if (renderState === "pending") {
|
||||
// Preserve a channel/DM intro across a new append only when this channel
|
||||
// already committed an authoritative empty timeline. On first load, the
|
||||
|
||||
@@ -19,6 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { useCommittedEmptyTimeline } from "./useCommittedEmptyTimeline";
|
||||
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock";
|
||||
import { MessageTimelineErrorCard } from "./MessageTimelineErrorCard";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
|
||||
@@ -52,7 +53,9 @@ type MessageTimelineProps = {
|
||||
displayName: string;
|
||||
participants: DirectMessageIntroParticipant[];
|
||||
} | null;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
onRetry?: () => void;
|
||||
entranceMessageId?: string | null;
|
||||
onEntranceMessageComplete?: (messageId: string) => void;
|
||||
emptyTitle?: string;
|
||||
@@ -163,7 +166,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
messages,
|
||||
mainEntries,
|
||||
threadSummaries,
|
||||
isError = false,
|
||||
isLoading = false,
|
||||
onRetry,
|
||||
entranceMessageId = null,
|
||||
onEntranceMessageComplete,
|
||||
emptyTitle = "No messages yet",
|
||||
@@ -299,10 +304,12 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const timelineBodySurface = selectTimelineBodySurface({
|
||||
deferredCount: deferredMessages.length,
|
||||
preserveSettledEmptyIntro,
|
||||
isError,
|
||||
isLoading: timelineIsLoading,
|
||||
liveCount: messages.length,
|
||||
});
|
||||
const showTimelineSkeleton = timelineBodySurface === "skeleton";
|
||||
const showTimelineError = timelineBodySurface === "error";
|
||||
const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] =
|
||||
React.useState(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes
|
||||
@@ -425,15 +432,17 @@ const MessageTimelineBase = React.forwardRef<
|
||||
[onVirtualizerAtBottomStateChange, queueSemanticBottom],
|
||||
);
|
||||
|
||||
const timelineIntroSurface = selectTimelineIntroSurface({
|
||||
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
|
||||
hasDirectMessageIntro: directMessageIntro !== null,
|
||||
hasReachedChannelStart:
|
||||
!isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) &&
|
||||
!isHoldingPrepend &&
|
||||
(messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)),
|
||||
isSkeletonVisible: showTimelineSkeleton,
|
||||
});
|
||||
const timelineIntroSurface = showTimelineError
|
||||
? null
|
||||
: selectTimelineIntroSurface({
|
||||
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
|
||||
hasDirectMessageIntro: directMessageIntro !== null,
|
||||
hasReachedChannelStart:
|
||||
!isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) &&
|
||||
!isHoldingPrepend &&
|
||||
(messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)),
|
||||
isSkeletonVisible: showTimelineSkeleton,
|
||||
});
|
||||
const showDirectMessageIntro =
|
||||
timelineIntroSurface === "direct-message-intro";
|
||||
const showChannelIntro = timelineIntroSurface === "channel-intro";
|
||||
@@ -765,7 +774,10 @@ const MessageTimelineBase = React.forwardRef<
|
||||
showChannelIntroOnly
|
||||
? "pt-[var(--channel-top-chrome-height,4.5rem)]"
|
||||
: channelChrome.contentPadding,
|
||||
(showIntro || showGenericEmpty || showMessageList) &&
|
||||
(showIntro ||
|
||||
showTimelineError ||
|
||||
showGenericEmpty ||
|
||||
showMessageList) &&
|
||||
"min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
@@ -784,7 +796,8 @@ const MessageTimelineBase = React.forwardRef<
|
||||
className={cn(
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
useTimelineVirtualizer && "min-h-0 flex-1",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
(showIntro || showTimelineError || showGenericEmpty) &&
|
||||
"min-h-full",
|
||||
showMessageList &&
|
||||
!showIntro &&
|
||||
!useTimelineVirtualizer &&
|
||||
@@ -794,6 +807,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
{showTimelineSkeleton ? (
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
) : null}
|
||||
{showTimelineError ? (
|
||||
<MessageTimelineErrorCard onRetry={onRetry} />
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
/** Retryable terminal failure for a channel history load with no cached rows. */
|
||||
export function MessageTimelineErrorCard({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="mt-auto rounded-2xl border border-dashed border-destructive/50 bg-destructive/5 px-6 py-10 text-center shadow-xs"
|
||||
data-testid="message-timeline-error"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
Couldn't load messages
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The channel history didn't load. Check your connection and try
|
||||
again.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
className="mt-4"
|
||||
data-testid="message-timeline-retry"
|
||||
onClick={onRetry}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user