mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state (#1581)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -227,7 +227,11 @@ const overrides = new Map([
|
||||
// +18: pendingImetaForPersistRef (local snapshot ref) + synchronous restore
|
||||
// path writes in the draft-key effect body, fixing the image-drop bug on
|
||||
// top-level nav switch (StrictMode simulate-unmount race on remount).
|
||||
["src/features/messages/ui/MessageComposer.tsx", 1021],
|
||||
// +12 autoSubmitDraftKey/onAutoSubmitComplete props + onAutoSubmitCompleteRef
|
||||
// + mount-only useEffect for the Drafts-panel "Send message" confirm-dialog
|
||||
// flow. Load-bearing feature growth; queued to split with the rest of this
|
||||
// list.
|
||||
["src/features/messages/ui/MessageComposer.tsx", 1033],
|
||||
]);
|
||||
|
||||
await runFileSizeCheck({
|
||||
|
||||
@@ -147,6 +147,12 @@ export function useAppNavigation() {
|
||||
options?: {
|
||||
/** Open the agent activity pane for this agent pubkey on arrival. */
|
||||
agentSession?: string;
|
||||
/**
|
||||
* When set, the main composer auto-submits the draft with this key
|
||||
* once on mount. Clears itself (via `?autoSend` search param) after
|
||||
* firing. Used by the Drafts panel "Send message" confirm flow.
|
||||
*/
|
||||
autoSend?: string;
|
||||
messageId?: string;
|
||||
replace?: boolean;
|
||||
threadRootId?: string | null;
|
||||
@@ -168,6 +174,7 @@ export function useAppNavigation() {
|
||||
...(options?.agentSession
|
||||
? { agentSession: options.agentSession }
|
||||
: {}),
|
||||
...(options?.autoSend ? { autoSend: options.autoSend } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { RelayEvent } from "@/shared/api/types";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
type ChannelRouteScreenProps = {
|
||||
autoSendDraftKey: string | null;
|
||||
channelId: string;
|
||||
selectedPostId: string | null;
|
||||
targetMessageId: string | null;
|
||||
@@ -94,6 +95,7 @@ async function fetchRouteTargetEvents(
|
||||
}
|
||||
|
||||
export function ChannelRouteScreen({
|
||||
autoSendDraftKey,
|
||||
channelId,
|
||||
selectedPostId,
|
||||
targetMessageId,
|
||||
@@ -195,6 +197,7 @@ export function ChannelRouteScreen({
|
||||
return (
|
||||
<ChannelScreen
|
||||
activeChannel={activeChannel}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
currentIdentity={identityQuery.data}
|
||||
currentProfile={profileQuery.data}
|
||||
onCloseForumPost={() => {
|
||||
|
||||
@@ -39,6 +39,7 @@ function ForumPostRouteComponent() {
|
||||
fallback={<ViewLoadingFallback includeHeader kind="forum" />}
|
||||
>
|
||||
<ChannelRouteScreen
|
||||
autoSendDraftKey={null}
|
||||
channelId={channelId}
|
||||
selectedPostId={postId}
|
||||
targetMessageId={null}
|
||||
|
||||
@@ -11,6 +11,12 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
type ChannelRouteSearch = {
|
||||
agentSession?: string;
|
||||
/**
|
||||
* When set, the composer on mount will auto-submit its loaded draft once,
|
||||
* then clear this param. Value is the draft key that was loaded so the
|
||||
* composer can verify it has the right draft before firing.
|
||||
*/
|
||||
autoSend?: string;
|
||||
messageId?: string;
|
||||
profile?: string;
|
||||
profileTab?: ProfilePanelTab;
|
||||
@@ -28,6 +34,7 @@ function validateChannelSearch(
|
||||
): ChannelRouteSearch {
|
||||
return {
|
||||
agentSession: nonEmptyString(search.agentSession),
|
||||
autoSend: nonEmptyString(search.autoSend),
|
||||
messageId: nonEmptyString(search.messageId),
|
||||
profile: nonEmptyString(search.profile),
|
||||
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
|
||||
@@ -56,6 +63,7 @@ function ChannelRouteComponent() {
|
||||
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
|
||||
>
|
||||
<ChannelRouteScreen
|
||||
autoSendDraftKey={search.autoSend ?? null}
|
||||
channelId={channelId}
|
||||
selectedPostId={null}
|
||||
targetMessageId={search.messageId ?? null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
|
||||
@@ -61,6 +62,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
agentPubkeysPending = false,
|
||||
agentSessionAgents,
|
||||
activityAgents = agentSessionAgents,
|
||||
autoSendDraftKey = null,
|
||||
onAutoSendComplete = null,
|
||||
botTypingEntries,
|
||||
channelFind,
|
||||
channelManagementOpen = false,
|
||||
@@ -148,6 +151,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
const welcomeComposerHideTimerRef = React.useRef<number | null>(null);
|
||||
const [welcomeComposerBannerState, setWelcomeComposerBannerState] =
|
||||
React.useState<WelcomeComposerBannerState>("prompt");
|
||||
const { goChannel } = useAppNavigation();
|
||||
const mainComposerMedia = useMediaUpload();
|
||||
const isNonMemberView =
|
||||
activeChannel !== null &&
|
||||
@@ -156,6 +160,20 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
!activeChannel.archivedAt;
|
||||
const hasMainComposerOverlay = !isNonMemberView;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
// Clear the ?autoSend search param once the auto-submit fires so
|
||||
// back-navigation cannot re-trigger the send.
|
||||
// When `onAutoSendComplete` is provided it does a surgical single-key clear
|
||||
// that preserves `?thread` and all other panel search state (required for
|
||||
// the thread-draft send path so the thread panel does not unmount before the
|
||||
// deferred setTimeout(0) submit fires). The goChannel fallback is kept for
|
||||
// callers that do not supply the prop (e.g. isolated tests / older wrappers).
|
||||
const handleAutoSubmitComplete = React.useCallback(() => {
|
||||
if (onAutoSendComplete) {
|
||||
onAutoSendComplete();
|
||||
} else if (activeChannelId) {
|
||||
void goChannel(activeChannelId, { replace: true });
|
||||
}
|
||||
}, [activeChannelId, goChannel, onAutoSendComplete]);
|
||||
const huddleMemberPubkeys = React.useMemo(
|
||||
() => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey),
|
||||
[activeChannel, agentPubkeys, currentPubkey],
|
||||
@@ -668,6 +686,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
containerClassName="px-5"
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={mainEditTarget}
|
||||
autoSubmitDraftKey={autoSendDraftKey}
|
||||
onAutoSubmitComplete={handleAutoSubmitComplete}
|
||||
isSending={isSending}
|
||||
mediaController={mainComposerMedia}
|
||||
onCancelEdit={onCancelEdit}
|
||||
@@ -760,6 +780,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
onAutoSubmitComplete={handleAutoSubmitComplete}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelThreadReply}
|
||||
onClose={onCloseThread}
|
||||
|
||||
@@ -19,6 +19,20 @@ export type ChannelPaneProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
agentPubkeysPending?: boolean;
|
||||
agentSessionAgents: ChannelAgentSessionAgent[];
|
||||
/**
|
||||
* When non-null, the main composer fires `submitMessage` once after loading
|
||||
* the draft identified by this key — i.e. the user clicked "Send message"
|
||||
* in the Drafts panel and confirmed. Cleared by the composer after firing so
|
||||
* back-navigation cannot re-trigger.
|
||||
*/
|
||||
autoSendDraftKey?: string | null;
|
||||
/**
|
||||
* Called after the auto-submit guard fires to surgically clear `?autoSend`
|
||||
* from the URL while preserving `?thread` and all other panel search state.
|
||||
* If omitted, ChannelPane falls back to a full goChannel() re-navigation
|
||||
* (safe for the main-composer path, which carries no URL-backed thread).
|
||||
*/
|
||||
onAutoSendComplete?: (() => void) | null;
|
||||
botTypingEntries: TypingIndicatorEntry[];
|
||||
channelFind: ReturnType<typeof useChannelFind>;
|
||||
channelManagementOpen?: boolean;
|
||||
|
||||
@@ -84,6 +84,7 @@ const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
|
||||
EMPTY_RELAY_EVENTS: RelayEvent[] = [];
|
||||
export function ChannelScreen({
|
||||
activeChannel,
|
||||
autoSendDraftKey,
|
||||
currentIdentity,
|
||||
currentProfile,
|
||||
onCloseForumPost,
|
||||
@@ -112,6 +113,7 @@ export function ChannelScreen({
|
||||
} = useAppShell();
|
||||
const {
|
||||
channelManagementOpen,
|
||||
clearAutoSend,
|
||||
clearMessageRouteTarget,
|
||||
openAgentSessionChannelId,
|
||||
openAgentSessionPubkey,
|
||||
@@ -846,6 +848,8 @@ export function ChannelScreen({
|
||||
agentPubkeys={agentPubkeys}
|
||||
agentPubkeysPending={agentPubkeysPending}
|
||||
agentSessionAgents={agentSessionAgents}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
onAutoSendComplete={clearAutoSend}
|
||||
botTypingEntries={botTypingEntries}
|
||||
channelFind={channelFind}
|
||||
channelManagementOpen={channelManagementOpen}
|
||||
|
||||
@@ -7,6 +7,13 @@ import type {
|
||||
|
||||
export type ChannelScreenProps = {
|
||||
activeChannel: Channel | null;
|
||||
/**
|
||||
* When non-null, the main channel composer auto-submits once on mount after
|
||||
* loading the draft identified by this key. The route component clears the
|
||||
* `?autoSend` search param after the submit fires so back-navigation does
|
||||
* not re-trigger. Value must match the composer's `effectiveDraftKey`.
|
||||
*/
|
||||
autoSendDraftKey: string | null;
|
||||
currentIdentity?: Identity;
|
||||
currentProfile?: Profile;
|
||||
onCloseForumPost: () => void;
|
||||
|
||||
37
desktop/src/features/channels/ui/channelSearchKeys.ts
Normal file
37
desktop/src/features/channels/ui/channelSearchKeys.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* URL search-param keys managed by the channel-panel history state.
|
||||
*
|
||||
* Kept in a separate pure module (no React / router imports) so tests can
|
||||
* import and assert the patch contracts without a browser environment.
|
||||
*/
|
||||
|
||||
export const CHANNEL_SEARCH_KEYS = [
|
||||
"agentSession",
|
||||
"agentSessionChannel",
|
||||
"autoSend",
|
||||
"channelManagement",
|
||||
"messageId",
|
||||
"profile",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
"thread",
|
||||
"threadRootId",
|
||||
] as const;
|
||||
|
||||
export type ChannelSearchKey = (typeof CHANNEL_SEARCH_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Returns the search-param patch that clears only the `autoSend` trigger.
|
||||
*
|
||||
* Exported so tests can verify the patch is surgical — it must not include
|
||||
* `thread` or any other panel key that would collapse open panels. This is
|
||||
* the regression guard for the "auto-submit clear drops the thread route"
|
||||
* defect: a `goChannel()` re-navigation drops every search key including
|
||||
* `thread`; this patch removes only `autoSend` via `applyPatch`, preserving
|
||||
* the thread panel across the deferred `setTimeout(0)` submit.
|
||||
*/
|
||||
export function buildAutoSendClearPatch(): Partial<
|
||||
Record<ChannelSearchKey, string | null>
|
||||
> {
|
||||
return { autoSend: null };
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
type HistorySearchSetterOptions,
|
||||
useHistorySearchState,
|
||||
} from "@/shared/hooks/useHistorySearchState";
|
||||
import {
|
||||
buildAutoSendClearPatch,
|
||||
CHANNEL_SEARCH_KEYS,
|
||||
} from "./channelSearchKeys";
|
||||
export type { ChannelSearchKey } from "./channelSearchKeys";
|
||||
|
||||
/**
|
||||
* Auxiliary-panel state for the channel routes, backed by URL search params
|
||||
@@ -21,7 +26,9 @@ import {
|
||||
* tab), `agentSession` (agent session panel pubkey), `agentSessionChannel`
|
||||
* (optional channel scope for the agent session panel), `channelManagement`
|
||||
* (presence flag for the channel-management panel — open/closed only, so it
|
||||
* carries a sentinel `"1"` rather than an id).
|
||||
* carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit
|
||||
* trigger — cleared surgically after the auto-submit fires so `thread` and
|
||||
* all other panel state are preserved).
|
||||
*/
|
||||
|
||||
export type PanelSetterOptions = HistorySearchSetterOptions;
|
||||
@@ -31,18 +38,6 @@ export type PanelValueSetter = (
|
||||
options?: PanelSetterOptions,
|
||||
) => void;
|
||||
|
||||
const CHANNEL_SEARCH_KEYS = [
|
||||
"agentSession",
|
||||
"agentSessionChannel",
|
||||
"channelManagement",
|
||||
"messageId",
|
||||
"profile",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
"thread",
|
||||
"threadRootId",
|
||||
] as const;
|
||||
|
||||
const CHANNEL_MANAGEMENT_OPEN_VALUE = "1";
|
||||
|
||||
export function useChannelPanelHistoryState() {
|
||||
@@ -105,8 +100,19 @@ export function useChannelPanelHistoryState() {
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
// Clears only the ?autoSend param, preserving `thread` and all other panel
|
||||
// search state. Use this instead of a full goChannel() re-navigation so the
|
||||
// thread panel does not unmount between the auto-submit trigger clear and the
|
||||
// deferred setTimeout(0) send.
|
||||
const clearAutoSend = React.useCallback(
|
||||
(options?: PanelSetterOptions) =>
|
||||
applyPatch(buildAutoSendClearPatch(), { replace: true, ...options }),
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
return {
|
||||
channelManagementOpen: values.channelManagement != null,
|
||||
clearAutoSend,
|
||||
clearMessageRouteTarget,
|
||||
openAgentSessionChannelId: values.agentSessionChannel,
|
||||
openAgentSessionPubkey: values.agentSession,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { formatTime } from "@/features/messages/lib/dateFormatters";
|
||||
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useActiveDraftCount } from "@/features/messages/ui/DraftsPanel";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
@@ -116,6 +117,10 @@ export function HomeView({
|
||||
const isMessagesMode = !isReminders && !isDrafts;
|
||||
const remindersQuery = useRemindersQuery(currentPubkey);
|
||||
const dueReminderCount = countDueReminders(remindersQuery.data ?? []);
|
||||
// Active draft count for the badge. When the Drafts panel is closed,
|
||||
// rootStatusMap is empty so orphaned drafts are counted optimistically.
|
||||
// They drop from the count once the panel opens and relay confirms deletion.
|
||||
const activeDraftCount = useActiveDraftCount(new Map());
|
||||
// `?item=` is Messages-mode-only machinery: a reminder never enters the
|
||||
// FeedItem selection model, so reload while in Reminders mode keeps a stale
|
||||
// `?item=` unconsumed and does not snap back to a feed-item detail view.
|
||||
@@ -547,6 +552,7 @@ export function HomeView({
|
||||
<InboxListPane
|
||||
activeReminderEventIds={activeReminderEventIds}
|
||||
agentPubkeys={inboxAgentPubkeys}
|
||||
activeDraftCount={activeDraftCount}
|
||||
doneSet={effectiveDoneSet}
|
||||
dueReminderCount={dueReminderCount}
|
||||
filter={filter}
|
||||
|
||||
@@ -101,6 +101,7 @@ function ActivityLabel({
|
||||
type InboxListPaneProps = {
|
||||
activeReminderEventIds?: ReadonlySet<string>;
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
activeDraftCount: number;
|
||||
doneSet: ReadonlySet<string>;
|
||||
filter: InboxFilter;
|
||||
items: InboxItem[];
|
||||
@@ -121,6 +122,7 @@ type InboxListPaneProps = {
|
||||
export function InboxListPane({
|
||||
activeReminderEventIds,
|
||||
agentPubkeys,
|
||||
activeDraftCount,
|
||||
doneSet,
|
||||
filter,
|
||||
items,
|
||||
@@ -456,6 +458,13 @@ export function InboxListPane({
|
||||
>
|
||||
{dueReminderCount}
|
||||
</span>
|
||||
) : activeDraftCount > 0 ? (
|
||||
<span
|
||||
className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-background bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
|
||||
data-testid="inbox-draft-badge"
|
||||
>
|
||||
{activeDraftCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -481,6 +490,14 @@ export function InboxListPane({
|
||||
>
|
||||
{dueReminderCount}
|
||||
</span>
|
||||
) : option.value === "drafts" &&
|
||||
activeDraftCount > 0 ? (
|
||||
<span
|
||||
className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
|
||||
data-testid="inbox-draft-badge-option"
|
||||
>
|
||||
{activeDraftCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
163
desktop/src/features/messages/lib/useDraftRootStatus.test.mjs
Normal file
163
desktop/src/features/messages/lib/useDraftRootStatus.test.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Unit tests for useDraftRootStatus helpers.
|
||||
*
|
||||
* Tests import the ACTUAL exported `classifyError` and `deriveActiveDraftCount`
|
||||
* (via DraftsPanel) so any implementation change is caught immediately.
|
||||
*
|
||||
* Tests cover:
|
||||
* - classifyError: "event not found" → deleted; other errors → error
|
||||
* - deriveActiveDraftCount: exclusion by status, channel-root pass-through
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// ── Browser-global shim for DraftsPanel imports ───────────────────────────────
|
||||
|
||||
function makeLocalStorage() {
|
||||
const store = new Map();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i) => [...store.keys()][i] ?? null,
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: (key) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = { localStorage: makeLocalStorage() };
|
||||
} else {
|
||||
globalThis.window.localStorage = makeLocalStorage();
|
||||
}
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
get: () => globalThis.window.localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Import the real exported helpers.
|
||||
import { classifyError } from "./useDraftRootStatus.ts";
|
||||
import { deriveActiveDraftCount } from "../ui/DraftsPanel.tsx";
|
||||
|
||||
// ── classifyError: string errors ─────────────────────────────────────────────
|
||||
|
||||
test("classifyError_event_not_found_string_returns_deleted", () => {
|
||||
assert.equal(classifyError("event not found"), "deleted");
|
||||
});
|
||||
|
||||
test("classifyError_event_not_found_string_with_prefix_returns_deleted", () => {
|
||||
// The tauri command error is exactly "event not found" (see messages.rs:419)
|
||||
// but we test a slightly prefixed variant in case the format changes.
|
||||
assert.equal(classifyError("get_event: event not found"), "deleted");
|
||||
});
|
||||
|
||||
test("classifyError_transport_failure_string_returns_error", () => {
|
||||
assert.equal(classifyError("transport error: connection refused"), "error");
|
||||
});
|
||||
|
||||
test("classifyError_empty_string_returns_error", () => {
|
||||
assert.equal(classifyError(""), "error");
|
||||
});
|
||||
|
||||
test("classifyError_auth_error_string_returns_error", () => {
|
||||
assert.equal(classifyError("unauthorized: token expired"), "error");
|
||||
});
|
||||
|
||||
test("classifyError_serialize_error_string_returns_error", () => {
|
||||
assert.equal(classifyError("serialize event: invalid json"), "error");
|
||||
});
|
||||
|
||||
// ── classifyError: Error instances ───────────────────────────────────────────
|
||||
|
||||
test("classifyError_Error_instance_with_event_not_found_returns_deleted", () => {
|
||||
assert.equal(classifyError(new Error("event not found")), "deleted");
|
||||
});
|
||||
|
||||
test("classifyError_Error_instance_with_other_message_returns_error", () => {
|
||||
assert.equal(classifyError(new Error("network failure")), "error");
|
||||
});
|
||||
|
||||
// ── Only deleted excludes from count ─────────────────────────────────────────
|
||||
|
||||
test("only_deleted_status_excludes_draft_from_count", () => {
|
||||
function wouldExclude(rootStatus) {
|
||||
return rootStatus === "deleted";
|
||||
}
|
||||
|
||||
assert.equal(wouldExclude("deleted"), true, "deleted must be excluded");
|
||||
assert.equal(
|
||||
wouldExclude("checking"),
|
||||
false,
|
||||
"checking must NOT be excluded",
|
||||
);
|
||||
assert.equal(
|
||||
wouldExclude("available"),
|
||||
false,
|
||||
"available must NOT be excluded",
|
||||
);
|
||||
assert.equal(wouldExclude("error"), false, "error must NOT be excluded");
|
||||
});
|
||||
|
||||
// ── deriveActiveDraftCount (imported from DraftsPanel) ───────────────────────
|
||||
|
||||
test("deriveActiveDraftCount_excludes_thread_draft_with_deleted_root", () => {
|
||||
const drafts = [{ key: "thread:root-aaa", draft: {} }];
|
||||
const statusMap = new Map([["root-aaa", "deleted"]]);
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 0);
|
||||
});
|
||||
|
||||
test("deriveActiveDraftCount_includes_thread_draft_with_available_root", () => {
|
||||
const drafts = [{ key: "thread:root-aaa", draft: {} }];
|
||||
const statusMap = new Map([["root-aaa", "available"]]);
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
|
||||
});
|
||||
|
||||
test("deriveActiveDraftCount_includes_thread_draft_with_checking_root", () => {
|
||||
const drafts = [{ key: "thread:root-aaa", draft: {} }];
|
||||
const statusMap = new Map([["root-aaa", "checking"]]);
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
|
||||
});
|
||||
|
||||
test("deriveActiveDraftCount_includes_thread_draft_with_error_root", () => {
|
||||
const drafts = [{ key: "thread:root-aaa", draft: {} }];
|
||||
const statusMap = new Map([["root-aaa", "error"]]);
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
|
||||
});
|
||||
|
||||
test("deriveActiveDraftCount_includes_channel_root_draft_regardless_of_status_map", () => {
|
||||
// Channel-root drafts (key = channel id, not "thread:...") cannot be orphaned.
|
||||
const drafts = [{ key: "chan-xyz", draft: {} }];
|
||||
const statusMap = new Map(); // empty — no entry for this key
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
|
||||
});
|
||||
|
||||
test("deriveActiveDraftCount_empty_rootStatusMap_treats_thread_drafts_as_available", () => {
|
||||
// When panel is closed, statusMap is empty — thread drafts count optimistically.
|
||||
const drafts = [
|
||||
{ key: "thread:root-111", draft: {} },
|
||||
{ key: "thread:root-222", draft: {} },
|
||||
{ key: "chan-direct", draft: {} },
|
||||
];
|
||||
const statusMap = new Map();
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 3);
|
||||
});
|
||||
|
||||
test("deriveActiveDraftCount_mixed_statuses_counts_correctly", () => {
|
||||
const drafts = [
|
||||
{ key: "thread:root-del", draft: {} }, // deleted — excluded
|
||||
{ key: "thread:root-ok", draft: {} }, // available — included
|
||||
{ key: "thread:root-chk", draft: {} }, // checking — included
|
||||
{ key: "thread:root-err", draft: {} }, // error — included
|
||||
{ key: "chan-direct", draft: {} }, // channel-root — included
|
||||
];
|
||||
const statusMap = new Map([
|
||||
["root-del", "deleted"],
|
||||
["root-ok", "available"],
|
||||
["root-chk", "checking"],
|
||||
["root-err", "error"],
|
||||
]);
|
||||
assert.equal(deriveActiveDraftCount(drafts, statusMap), 4);
|
||||
});
|
||||
87
desktop/src/features/messages/lib/useDraftRootStatus.ts
Normal file
87
desktop/src/features/messages/lib/useDraftRootStatus.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
|
||||
import { getEventById } from "@/shared/api/tauri";
|
||||
|
||||
/**
|
||||
* Root-existence status for a thread-draft's parent event.
|
||||
*
|
||||
* - `checking` — query in flight. Treated as available/counted (optimistic)
|
||||
* so a slow relay does not blink drafts out of the badge.
|
||||
* - `available` — `getEventById` resolved; root exists.
|
||||
* - `deleted` — ONLY when `get_event` returned the definitive
|
||||
* `"event not found"` string (relay query succeeded with zero
|
||||
* rows). Excluded from the badge count; open+send disabled;
|
||||
* "thread deleted" label shown.
|
||||
* - `error` — any other rejection (transport/auth/serialize). Treated
|
||||
* as available/counted; NOT labeled deleted. Re-checked on
|
||||
* next panel open.
|
||||
*/
|
||||
export type RootStatus = "checking" | "available" | "deleted" | "error";
|
||||
|
||||
const EVENT_NOT_FOUND_MESSAGE = "event not found";
|
||||
|
||||
export function classifyError(err: unknown): RootStatus {
|
||||
// Only the definitive relay-returned string maps to `deleted`.
|
||||
// Every other failure (transport, auth, serialization) is `error`.
|
||||
if (typeof err === "string" && err.includes(EVENT_NOT_FOUND_MESSAGE)) {
|
||||
return "deleted";
|
||||
}
|
||||
if (err instanceof Error && err.message.includes(EVENT_NOT_FOUND_MESSAGE)) {
|
||||
return "deleted";
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves root-existence status for a set of thread-root event IDs.
|
||||
*
|
||||
* **Query semantics (locked — Thufir will verify):**
|
||||
* - `staleTime: 0` — every query is always considered stale.
|
||||
* - `refetchOnMount: "always"` — re-fetch every time this hook mounts (i.e.
|
||||
* every time the Drafts panel opens), so the flag is genuinely per-panel-open
|
||||
* and a restored root flips `deleted` → `available` on next open.
|
||||
* - Query key = `["draftRootStatus", rootId]` — root id only, no timestamp.
|
||||
* React Query dedupes concurrent lookups within one open; never serves an
|
||||
* indefinitely-stale `deleted`.
|
||||
* - `deleted` is NEVER written into any persisted cache — it is a pure
|
||||
* read-through of the current lookup.
|
||||
* - Enabled only when `isOpen` is true so we don't burn relay RTTs when the
|
||||
* Drafts panel is closed.
|
||||
*
|
||||
* @param rootIds Deduplicated thread-root event IDs to check.
|
||||
* @param isOpen Whether the Drafts panel is currently visible.
|
||||
* @returns A `Map<rootId, RootStatus>` — one entry per input root id.
|
||||
*/
|
||||
export function useDraftRootStatus(
|
||||
rootIds: string[],
|
||||
isOpen: boolean,
|
||||
): Map<string, RootStatus> {
|
||||
const results = useQueries({
|
||||
queries: rootIds.map((rootId) => ({
|
||||
queryKey: ["draftRootStatus", rootId] as const,
|
||||
queryFn: () => getEventById(rootId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always" as const,
|
||||
retry: false,
|
||||
enabled: isOpen && rootId.length > 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const statusMap = new Map<string, RootStatus>();
|
||||
rootIds.forEach((rootId, index) => {
|
||||
const result = results[index];
|
||||
if (!result) {
|
||||
statusMap.set(rootId, "checking");
|
||||
return;
|
||||
}
|
||||
if (result.isPending || result.isFetching) {
|
||||
statusMap.set(rootId, "checking");
|
||||
} else if (result.isError) {
|
||||
statusMap.set(rootId, classifyError(result.error));
|
||||
} else {
|
||||
statusMap.set(rootId, "available");
|
||||
}
|
||||
});
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
@@ -3,6 +3,42 @@ import * as React from "react";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
|
||||
// ── Store reactivity ─────────────────────────────────────────────────────────
|
||||
// `useSyncExternalStore` requires a stable subscribe/getSnapshot pair.
|
||||
// localStorage is not reactive, so we maintain a module-level subscriber set
|
||||
// and version counter. Every write bumps the counter and notifies subscribers
|
||||
// so any component consuming `useDraftsSnapshot()` re-renders immediately.
|
||||
|
||||
type Subscriber = () => void;
|
||||
const _subscribers = new Set<Subscriber>();
|
||||
let _version = 0;
|
||||
|
||||
/** Notify all active subscribers. Called by every write path. */
|
||||
function notifySubscribers(): void {
|
||||
_version += 1;
|
||||
for (const sub of _subscribers) {
|
||||
sub();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToStore(callback: Subscriber): () => void {
|
||||
_subscribers.add(callback);
|
||||
return () => {
|
||||
_subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
function getStoreSnapshot(): number {
|
||||
return _version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to draft store changes. Returns an unsubscribe function.
|
||||
* Exported for unit-testing the subscriber notification contract.
|
||||
* Use `useDraftsSnapshot()` in React components.
|
||||
*/
|
||||
export { subscribeToStore, getStoreSnapshot };
|
||||
|
||||
export type DraftState = {
|
||||
content: string;
|
||||
selectionStart: number;
|
||||
@@ -181,6 +217,7 @@ export function saveDraftEntry(draftKey: string, draft: DraftState): void {
|
||||
map.set(draftKey, draft);
|
||||
evictOldest(map);
|
||||
flushStore(map);
|
||||
notifySubscribers();
|
||||
}
|
||||
|
||||
export function loadDraftEntry(draftKey: string): DraftState | undefined {
|
||||
@@ -192,6 +229,7 @@ export function clearDraftEntry(draftKey: string): void {
|
||||
if (map.has(draftKey)) {
|
||||
map.delete(draftKey);
|
||||
flushStore(map);
|
||||
notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +354,22 @@ export function markDraftSentEntry(
|
||||
map.delete(draftKey);
|
||||
evictOldest(map);
|
||||
flushStore(map);
|
||||
notifySubscribers();
|
||||
}
|
||||
|
||||
// ── Reactive hooks ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the current store version, re-rendering the component on every
|
||||
* draft write (save / clear / persist / markSent). The version number itself
|
||||
* is not meaningful — callers derive their actual data from the snapshot.
|
||||
*
|
||||
* Use this anywhere that needs to react to draft changes without polling:
|
||||
* - `DraftsPanel` (replaces manual `refreshDrafts` + `useEffect`)
|
||||
* - `useActiveDraftCount` (badge count)
|
||||
*/
|
||||
export function useDraftsSnapshot(): number {
|
||||
return React.useSyncExternalStore(subscribeToStore, getStoreSnapshot);
|
||||
}
|
||||
|
||||
export function useDrafts() {
|
||||
|
||||
232
desktop/src/features/messages/lib/useDraftsReactivity.test.mjs
Normal file
232
desktop/src/features/messages/lib/useDraftsReactivity.test.mjs
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Unit tests for useDrafts store reactivity.
|
||||
*
|
||||
* Tests verify the actual subscriber-notification contract:
|
||||
* - A write path (saveDraftEntry / clearDraftEntry / persistDraftEntry /
|
||||
* markDraftSentEntry) fires each registered subscriber exactly once.
|
||||
* - The snapshot version increments on each write.
|
||||
* - Unsubscribed callbacks are NOT called.
|
||||
* - clearDraftEntry on a nonexistent key is a no-op (no notification).
|
||||
*
|
||||
* Uses the exported `subscribeToStore` + `getStoreSnapshot` primitives so the
|
||||
* contract is tested at the source — no React renderer needed.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// ── Browser-global shim ───────────────────────────────────────────────────────
|
||||
|
||||
function makeLocalStorage() {
|
||||
const store = new Map();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i) => [...store.keys()][i] ?? null,
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: (key) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
function installFreshLocalStorage() {
|
||||
const ls = makeLocalStorage();
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = { localStorage: ls };
|
||||
} else {
|
||||
globalThis.window.localStorage = ls;
|
||||
}
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
get: () => globalThis.window.localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return ls;
|
||||
}
|
||||
|
||||
installFreshLocalStorage();
|
||||
|
||||
import {
|
||||
clearAllDrafts,
|
||||
clearDraftEntry,
|
||||
getStoreSnapshot,
|
||||
initDraftStore,
|
||||
markDraftSentEntry,
|
||||
persistDraftEntry,
|
||||
saveDraftEntry,
|
||||
subscribeToStore,
|
||||
} from "./useDrafts.ts";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function setup(pubkey = "pubkey-reactivity") {
|
||||
installFreshLocalStorage();
|
||||
clearAllDrafts();
|
||||
initDraftStore(pubkey);
|
||||
}
|
||||
|
||||
function makeDraft(overrides = {}) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
content: "hello",
|
||||
selectionStart: 5,
|
||||
selectionEnd: 5,
|
||||
channelId: "chan-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pendingImeta: [],
|
||||
spoileredAttachmentUrls: [],
|
||||
status: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe, run `action`, unsubscribe, and return {callCount, versionBefore, versionAfter}.
|
||||
*/
|
||||
function observeWrite(action) {
|
||||
let callCount = 0;
|
||||
const versionBefore = getStoreSnapshot();
|
||||
const unsub = subscribeToStore(() => {
|
||||
callCount += 1;
|
||||
});
|
||||
action();
|
||||
unsub();
|
||||
const versionAfter = getStoreSnapshot();
|
||||
return { callCount, versionBefore, versionAfter };
|
||||
}
|
||||
|
||||
// ── saveDraftEntry notifies subscriber and bumps version ──────────────────────
|
||||
|
||||
test("saveDraftEntry_notifies_subscriber_and_bumps_version", () => {
|
||||
setup();
|
||||
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
|
||||
saveDraftEntry("chan-save", makeDraft());
|
||||
});
|
||||
assert.equal(callCount, 1, "subscriber must be called exactly once");
|
||||
assert.equal(
|
||||
versionAfter,
|
||||
versionBefore + 1,
|
||||
"version must increment by 1 on saveDraftEntry",
|
||||
);
|
||||
});
|
||||
|
||||
// ── clearDraftEntry notifies when key exists ──────────────────────────────────
|
||||
|
||||
test("clearDraftEntry_notifies_subscriber_when_key_exists", () => {
|
||||
setup();
|
||||
saveDraftEntry("chan-del", makeDraft({ content: "to delete" }));
|
||||
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
|
||||
clearDraftEntry("chan-del");
|
||||
});
|
||||
assert.equal(
|
||||
callCount,
|
||||
1,
|
||||
"subscriber must be called exactly once on delete",
|
||||
);
|
||||
assert.equal(
|
||||
versionAfter,
|
||||
versionBefore + 1,
|
||||
"version must increment on delete",
|
||||
);
|
||||
});
|
||||
|
||||
// ── clearDraftEntry on nonexistent key is a no-op ─────────────────────────────
|
||||
|
||||
test("clearDraftEntry_is_noop_for_nonexistent_key", () => {
|
||||
setup();
|
||||
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
|
||||
clearDraftEntry("key-that-does-not-exist");
|
||||
});
|
||||
assert.equal(
|
||||
callCount,
|
||||
0,
|
||||
"subscriber must NOT be called for nonexistent key",
|
||||
);
|
||||
assert.equal(
|
||||
versionAfter,
|
||||
versionBefore,
|
||||
"version must NOT change for nonexistent key",
|
||||
);
|
||||
});
|
||||
|
||||
// ── persistDraftEntry notifies on save ───────────────────────────────────────
|
||||
|
||||
test("persistDraftEntry_non_empty_notifies_subscriber", () => {
|
||||
setup();
|
||||
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
|
||||
persistDraftEntry("chan-p", "some content", "chan-p", [], []);
|
||||
});
|
||||
assert.equal(callCount, 1, "subscriber must be called on persist");
|
||||
assert.equal(
|
||||
versionAfter,
|
||||
versionBefore + 1,
|
||||
"version must increment on persist",
|
||||
);
|
||||
});
|
||||
|
||||
// ── persistDraftEntry clears on empty content ─────────────────────────────────
|
||||
|
||||
test("persistDraftEntry_empty_content_notifies_subscriber", () => {
|
||||
setup();
|
||||
// First save something.
|
||||
persistDraftEntry("chan-p2", "will be cleared", "chan-p2", [], []);
|
||||
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
|
||||
// Whitespace-only triggers a clear.
|
||||
persistDraftEntry("chan-p2", " ", "chan-p2", [], []);
|
||||
});
|
||||
assert.equal(
|
||||
callCount,
|
||||
1,
|
||||
"subscriber must be called when content is cleared",
|
||||
);
|
||||
assert.equal(
|
||||
versionAfter,
|
||||
versionBefore + 1,
|
||||
"version must increment on clear",
|
||||
);
|
||||
});
|
||||
|
||||
// ── markDraftSentEntry notifies subscriber ────────────────────────────────────
|
||||
|
||||
test("markDraftSentEntry_notifies_subscriber", () => {
|
||||
setup();
|
||||
persistDraftEntry("chan-sent", "content to send", "chan-sent", [], []);
|
||||
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
|
||||
markDraftSentEntry("chan-sent", "content to send", "chan-sent", [], []);
|
||||
});
|
||||
assert.equal(callCount, 1, "subscriber must be called on markSent");
|
||||
assert.equal(
|
||||
versionAfter,
|
||||
versionBefore + 1,
|
||||
"version must increment on markSent",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Unsubscribed callback is NOT called ───────────────────────────────────────
|
||||
|
||||
test("unsubscribed_callback_is_not_called", () => {
|
||||
setup();
|
||||
let callCount = 0;
|
||||
const unsub = subscribeToStore(() => {
|
||||
callCount += 1;
|
||||
});
|
||||
// Immediately unsubscribe before the write.
|
||||
unsub();
|
||||
saveDraftEntry("chan-unsub", makeDraft());
|
||||
assert.equal(callCount, 0, "unsubscribed callback must NOT be called");
|
||||
});
|
||||
|
||||
// ── Multiple writes each bump version independently ──────────────────────────
|
||||
|
||||
test("multiple_writes_each_bump_version_independently", () => {
|
||||
setup();
|
||||
const v0 = getStoreSnapshot();
|
||||
saveDraftEntry("chan-a", makeDraft());
|
||||
const v1 = getStoreSnapshot();
|
||||
saveDraftEntry("chan-b", makeDraft());
|
||||
const v2 = getStoreSnapshot();
|
||||
assert.equal(v1, v0 + 1, "first write must bump version");
|
||||
assert.equal(v2, v1 + 1, "second write must bump version again");
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, Lock, Pencil, Trash2 } from "lucide-react";
|
||||
import { FileText, Lock, Pencil, Send, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
@@ -7,14 +7,27 @@ import {
|
||||
clearDraftEntry,
|
||||
getActiveDraftEntries,
|
||||
getSentDraftEntries,
|
||||
useDraftsSnapshot,
|
||||
type DraftState,
|
||||
} from "@/features/messages/lib/useDrafts";
|
||||
import {
|
||||
useDraftRootStatus,
|
||||
type RootStatus,
|
||||
} from "@/features/messages/lib/useDraftRootStatus";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
@@ -196,19 +209,96 @@ export function canOpenDraft(draft: DraftState, source: DraftSource): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a draft is sendable: same conditions as openable, plus root not deleted. */
|
||||
export function canSendDraft(
|
||||
draft: DraftState,
|
||||
source: DraftSource,
|
||||
rootStatus: RootStatus,
|
||||
): boolean {
|
||||
if (!canOpenDraft(draft, source)) return false;
|
||||
if (rootStatus === "deleted") return false;
|
||||
// Mirror the destination composer's stable disabled states so we never
|
||||
// offer a Send that will silently no-op:
|
||||
// - not a member: the composer rejects sends
|
||||
// - archived: read-only
|
||||
// - forum: forum posting is not wired
|
||||
// `isSending` is transient runtime state not visible from the panel — omit.
|
||||
const ch = source.channel;
|
||||
if (ch === null) return false;
|
||||
if (!ch.isMember) return false;
|
||||
if (ch.archivedAt !== null) return false;
|
||||
if (ch.channelType === "forum") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Send confirmation dialog ──────────────────────────────────────────────────
|
||||
|
||||
type SendConfirmDialogProps = {
|
||||
channelLabel: string;
|
||||
isDm: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
function SendConfirmDialog({
|
||||
channelLabel,
|
||||
isDm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open,
|
||||
}: SendConfirmDialogProps) {
|
||||
const destination = isDm ? channelLabel : `#${channelLabel}`;
|
||||
return (
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send message</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to send this message to {destination}?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button onClick={onCancel} size="sm" type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} size="sm" type="button">
|
||||
Send
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DraftRow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function DraftRow({
|
||||
entry,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onSend,
|
||||
rootStatus,
|
||||
source,
|
||||
}: {
|
||||
entry: DraftListEntry;
|
||||
onDelete: (draftKey: string) => void;
|
||||
onOpen: (entry: DraftListEntry) => void;
|
||||
onSend: (entry: DraftListEntry) => void;
|
||||
rootStatus: RootStatus;
|
||||
source: DraftSource;
|
||||
}) {
|
||||
const isSent = entry.draft.status === "sent";
|
||||
const canOpen = canOpenDraft(entry.draft, source);
|
||||
const isOrphaned = rootStatus === "deleted";
|
||||
const canOpen = canOpenDraft(entry.draft, source) && !isOrphaned;
|
||||
const canSend = canSendDraft(entry.draft, source, rootStatus);
|
||||
const isPrivate = source.channel?.visibility === "private";
|
||||
const isDm = source.channel?.channelType === "dm";
|
||||
const channelLabel = source.channel
|
||||
@@ -219,7 +309,10 @@ function DraftRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/draft-row relative rounded-md border border-border/70 bg-background transition-colors hover:bg-muted/40 focus-within:bg-muted/40"
|
||||
className={cn(
|
||||
"group/draft-row relative rounded-md border border-border/70 bg-background transition-colors hover:bg-muted/40 focus-within:bg-muted/40",
|
||||
isOrphaned && "opacity-50",
|
||||
)}
|
||||
data-testid={`home-draft-item-${entry.key}`}
|
||||
>
|
||||
<button
|
||||
@@ -229,13 +322,14 @@ function DraftRow({
|
||||
onClick={() => onOpen(entry)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 pr-0 transition-[padding] group-hover/draft-row:pr-16 group-focus-within/draft-row:pr-16">
|
||||
<div className="min-w-0 pr-0 transition-[padding] group-hover/draft-row:pr-20 group-focus-within/draft-row:pr-20">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isPrivate ? <Lock className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
source.channel ? "text-foreground" : "text-muted-foreground",
|
||||
isOrphaned && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{channelLabel}
|
||||
@@ -243,6 +337,14 @@ function DraftRow({
|
||||
<span className="shrink-0 text-muted-foreground/70">
|
||||
{formatDraftCreatedAt(entry.draft)}
|
||||
</span>
|
||||
{isOrphaned ? (
|
||||
<span
|
||||
className="shrink-0 rounded px-1 py-0.5 text-2xs font-medium text-destructive/70 ring-1 ring-destructive/30"
|
||||
data-testid={`home-draft-orphaned-label-${entry.key}`}
|
||||
>
|
||||
thread deleted
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 max-h-10 overflow-hidden text-sm font-medium leading-5 text-foreground">
|
||||
<Markdown
|
||||
@@ -254,15 +356,37 @@ function DraftRow({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Hover action buttons: edit / delete / send (order per spec) */}
|
||||
<div className="pointer-events-none absolute right-2 top-2 flex items-center gap-0.5 rounded-full bg-background/95 p-0.5 opacity-0 shadow-xs ring-1 ring-border/70 transition-opacity group-hover/draft-row:pointer-events-auto group-hover/draft-row:opacity-100 group-focus-within/draft-row:pointer-events-auto group-focus-within/draft-row:opacity-100">
|
||||
{isSent ? null : (
|
||||
<DraftRowActionButton
|
||||
disabled={!canOpen}
|
||||
label={canOpen ? "Open draft" : "No channel link"}
|
||||
onClick={() => onOpen(entry)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</DraftRowActionButton>
|
||||
<>
|
||||
<DraftRowActionButton
|
||||
disabled={!canOpen}
|
||||
label={
|
||||
canOpen
|
||||
? "Open draft"
|
||||
: isOrphaned
|
||||
? "Thread deleted"
|
||||
: "No channel link"
|
||||
}
|
||||
onClick={() => onOpen(entry)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</DraftRowActionButton>
|
||||
<DraftRowActionButton
|
||||
disabled={!canSend}
|
||||
label={
|
||||
canSend
|
||||
? "Send message"
|
||||
: isOrphaned
|
||||
? "Thread deleted"
|
||||
: "No channel link"
|
||||
}
|
||||
onClick={() => onSend(entry)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</DraftRowActionButton>
|
||||
</>
|
||||
)}
|
||||
<DraftRowActionButton
|
||||
label="Delete draft"
|
||||
@@ -275,27 +399,96 @@ function DraftRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared derivation: active draft count ────────────────────────────────────
|
||||
// The badge (InboxListPane/HomeView) and the panel both call
|
||||
// `deriveActiveDraftCount` with the same arguments so the derivation logic
|
||||
// cannot diverge. Note: the badge call-site feeds an empty rootStatusMap
|
||||
// (panel closed → queries disabled), so orphaned thread drafts count
|
||||
// optimistically until the panel opens and the relay confirms deletion.
|
||||
// This bounded eventual-consistency is the sanctioned design (Will, 2026-07-07).
|
||||
|
||||
/**
|
||||
* Derives the active (non-orphaned) draft count from the draft store snapshot
|
||||
* and root-status map. A draft is excluded from the count when its thread root
|
||||
* is definitively deleted (`"deleted"` status).
|
||||
*
|
||||
* @param activeDrafts Active draft entries from `getActiveDraftEntries()`.
|
||||
* @param rootStatusMap Root-status map from `useDraftRootStatus()`.
|
||||
* @returns Count of active drafts whose root is NOT deleted.
|
||||
*/
|
||||
export function deriveActiveDraftCount(
|
||||
activeDrafts: Array<{ key: string; draft: DraftState }>,
|
||||
rootStatusMap: Map<string, RootStatus>,
|
||||
): number {
|
||||
return activeDrafts.filter((entry) => {
|
||||
const threadRootId = getThreadRootId(entry.key);
|
||||
if (threadRootId === null) {
|
||||
// Channel-root draft — cannot be orphaned.
|
||||
return true;
|
||||
}
|
||||
const status = rootStatusMap.get(threadRootId) ?? "checking";
|
||||
// Exclude only on definitive `deleted`; `checking`/`error` are optimistic.
|
||||
return status !== "deleted";
|
||||
}).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive hook for the active (non-orphaned) draft count.
|
||||
*
|
||||
* Used by `HomeView` to thread the count to `InboxListPane` for the badge.
|
||||
* Uses the same `deriveActiveDraftCount` function as the panel so the
|
||||
* derivation logic cannot diverge.
|
||||
*
|
||||
* When the panel is closed, `rootStatusMap` is empty (queries disabled), so
|
||||
* thread-reply drafts are counted optimistically — orphaned roots only drop
|
||||
* out of the count once the panel opens and the relay lookups complete.
|
||||
* This bounded eventual-consistency is product-approved (Will, 2026-07-07).
|
||||
*/
|
||||
export function useActiveDraftCount(
|
||||
rootStatusMap: Map<string, RootStatus>,
|
||||
): number {
|
||||
// Re-render on every draft write via useDraftsSnapshot.
|
||||
useDraftsSnapshot();
|
||||
const activeDrafts = getActiveDraftEntries().filter(isVisibleDraft);
|
||||
return deriveActiveDraftCount(activeDrafts, rootStatusMap);
|
||||
}
|
||||
|
||||
// ── DraftsPanel ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function DraftsPanel() {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const currentPubkey = identityQuery.data?.pubkey;
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const [sections, setSections] =
|
||||
React.useState<DraftSection[]>(readDraftSections);
|
||||
|
||||
const refreshDrafts = React.useCallback(() => {
|
||||
setSections(readDraftSections());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshDrafts();
|
||||
}, [refreshDrafts]);
|
||||
// Collapse the old `sections` state + `refreshDrafts` pattern onto a
|
||||
// reactive snapshot: every draft write re-renders via useSyncExternalStore.
|
||||
useDraftsSnapshot();
|
||||
const sections = readDraftSections();
|
||||
|
||||
const drafts = React.useMemo(
|
||||
() => sections.flatMap((section) => section.entries),
|
||||
[sections],
|
||||
);
|
||||
|
||||
// Collect unique thread-root IDs from active drafts only (sent drafts cannot
|
||||
// be sent/orphaned). Deduplicated on root id — multiple drafts can share one.
|
||||
const threadRootIds = React.useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of sections.flatMap((s) =>
|
||||
s.status === "active" ? s.entries : [],
|
||||
)) {
|
||||
const rootId = getThreadRootId(entry.key);
|
||||
if (rootId) {
|
||||
ids.add(rootId);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}, [sections]);
|
||||
|
||||
// Panel is always mounted when visible; `isOpen=true` enables root queries.
|
||||
const rootStatusMap = useDraftRootStatus(threadRootIds, true);
|
||||
|
||||
const profilePubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
@@ -324,6 +517,11 @@ export function DraftsPanel() {
|
||||
[channelsQuery.data, currentPubkey, drafts, profiles],
|
||||
);
|
||||
|
||||
// Send confirmation dialog state.
|
||||
const [sendTarget, setSendTarget] = React.useState<DraftListEntry | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleOpen = React.useCallback(
|
||||
(entry: DraftListEntry) => {
|
||||
if (!entry.draft.channelId) {
|
||||
@@ -339,13 +537,41 @@ export function DraftsPanel() {
|
||||
[goChannel],
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
(draftKey: string) => {
|
||||
clearDraftEntry(draftKey);
|
||||
refreshDrafts();
|
||||
},
|
||||
[refreshDrafts],
|
||||
);
|
||||
const handleDelete = React.useCallback((draftKey: string) => {
|
||||
clearDraftEntry(draftKey);
|
||||
// No manual refresh needed — clearDraftEntry notifies subscribers and
|
||||
// useDraftsSnapshot() causes DraftsPanel to re-render automatically.
|
||||
}, []);
|
||||
|
||||
const handleSendRequest = React.useCallback((entry: DraftListEntry) => {
|
||||
setSendTarget(entry);
|
||||
}, []);
|
||||
|
||||
const handleSendCancel = React.useCallback(() => {
|
||||
setSendTarget(null);
|
||||
}, []);
|
||||
|
||||
const handleSendConfirm = React.useCallback(() => {
|
||||
if (!sendTarget) return;
|
||||
const entry = sendTarget;
|
||||
setSendTarget(null);
|
||||
|
||||
if (!entry.draft.channelId) return;
|
||||
|
||||
const threadRootId = getThreadRootId(entry.key);
|
||||
void goChannel(entry.draft.channelId, {
|
||||
...(threadRootId ? { messageId: threadRootId, threadRootId } : {}),
|
||||
autoSend: entry.key,
|
||||
});
|
||||
}, [sendTarget, goChannel]);
|
||||
|
||||
const sendDialogSource = sendTarget
|
||||
? (sources.get(sendTarget.key) ?? UNKNOWN_DRAFT_SOURCE)
|
||||
: UNKNOWN_DRAFT_SOURCE;
|
||||
const sendDialogIsDm = sendDialogSource.channel?.channelType === "dm";
|
||||
const sendDialogChannelLabel = sendDialogSource.channel
|
||||
? sendDialogSource.label
|
||||
: UNKNOWN_CHANNEL_LABEL;
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
@@ -357,23 +583,44 @@ export function DraftsPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{sections.map((section) => (
|
||||
<div className="space-y-2" key={section.status}>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{section.label}
|
||||
</h3>
|
||||
{section.entries.map((entry) => (
|
||||
<DraftRow
|
||||
entry={entry}
|
||||
key={entry.key}
|
||||
onDelete={handleDelete}
|
||||
onOpen={handleOpen}
|
||||
source={sources.get(entry.key) ?? UNKNOWN_DRAFT_SOURCE}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{sections.map((section) => (
|
||||
<div className="space-y-2" key={section.status}>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{section.label}
|
||||
</h3>
|
||||
{section.entries.map((entry) => {
|
||||
const threadRootId = getThreadRootId(entry.key);
|
||||
const rootStatus: RootStatus =
|
||||
threadRootId !== null
|
||||
? (rootStatusMap.get(threadRootId) ?? "checking")
|
||||
: "available";
|
||||
return (
|
||||
<DraftRow
|
||||
entry={entry}
|
||||
key={entry.key}
|
||||
onDelete={handleDelete}
|
||||
onOpen={handleOpen}
|
||||
onSend={handleSendRequest}
|
||||
rootStatus={rootStatus}
|
||||
source={sources.get(entry.key) ?? UNKNOWN_DRAFT_SOURCE}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sendTarget ? (
|
||||
<SendConfirmDialog
|
||||
channelLabel={sendDialogChannelLabel}
|
||||
isDm={sendDialogIsDm}
|
||||
onCancel={handleSendCancel}
|
||||
onConfirm={handleSendConfirm}
|
||||
open={true}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
/**
|
||||
* Unit tests for the canOpenDraft openability predicate in DraftsPanel.
|
||||
* Unit tests for the canOpenDraft and canSendDraft predicates in DraftsPanel.
|
||||
*
|
||||
* These tests import and exercise the ACTUAL exported `canOpenDraft` function —
|
||||
* not a restatement of its logic — so any regression (e.g. reverting isSent or
|
||||
* source.channel checks, or removing the export) breaks these tests immediately.
|
||||
* These tests import and exercise the ACTUAL exported functions —
|
||||
* not a restatement of logic — so any regression breaks these tests immediately.
|
||||
*
|
||||
* Three properties under test:
|
||||
* canOpenDraft properties under test:
|
||||
* (a) active draft + resolved channel → canOpen = true
|
||||
* (b) sent draft + resolved channel → canOpen = false (Delete-only)
|
||||
* (c) active draft + unresolved channel (null) → canOpen = false (false affordance guard)
|
||||
* (d) active draft + empty channelId → canOpen = false (belt-and-suspenders)
|
||||
*
|
||||
* canSendDraft additional gates (mirrors ChannelPane.isComposerDisabled):
|
||||
* (e) not a member → canSend = false
|
||||
* (f) archived channel → canSend = false
|
||||
* (g) forum channel → canSend = false
|
||||
* (h) deleted root → canSend = false
|
||||
* (i) all-clear active + member + non-archived + non-forum → canSend = true
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// canOpenDraft is a pure function — no browser globals or React needed.
|
||||
import { canOpenDraft } from "./DraftsPanel.tsx";
|
||||
// canOpenDraft and canSendDraft are pure functions — no browser globals or React needed.
|
||||
import { canOpenDraft, canSendDraft } from "./DraftsPanel.tsx";
|
||||
|
||||
// Minimal Channel stub — only the fields canOpenDraft reads (none; it checks null/non-null).
|
||||
// Minimal Channel stub — all fields canOpenDraft and canSendDraft read.
|
||||
const RESOLVED_CHANNEL = {
|
||||
id: "chan-1",
|
||||
visibility: "public",
|
||||
channelType: "channel",
|
||||
channelType: "stream",
|
||||
isMember: true,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
function activeDraft(channelId = "chan-1") {
|
||||
@@ -104,3 +112,102 @@ test("canOpenDraft_sent_null_channel_returns_false", () => {
|
||||
"sent draft with unresolved channel should not be openable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── canSendDraft: happy path ──────────────────────────────────────────────────
|
||||
|
||||
test("canSendDraft_active_member_stream_channel_returns_true", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
|
||||
assert.equal(canSendDraft(draft, source, "available"), true);
|
||||
});
|
||||
|
||||
// ── canSendDraft: deleted root ────────────────────────────────────────────────
|
||||
|
||||
test("canSendDraft_deleted_root_returns_false", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "deleted"),
|
||||
false,
|
||||
"draft with deleted thread root must not be sendable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── canSendDraft: not a member ────────────────────────────────────────────────
|
||||
|
||||
test("canSendDraft_not_member_returns_false", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = {
|
||||
channel: { ...RESOLVED_CHANNEL, isMember: false },
|
||||
label: "#general",
|
||||
};
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "available"),
|
||||
false,
|
||||
"draft in a channel the user is not a member of must not be sendable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── canSendDraft: archived channel ────────────────────────────────────────────
|
||||
|
||||
test("canSendDraft_archived_channel_returns_false", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = {
|
||||
channel: { ...RESOLVED_CHANNEL, archivedAt: "2026-01-01T00:00:00Z" },
|
||||
label: "#archived",
|
||||
};
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "available"),
|
||||
false,
|
||||
"draft in an archived channel must not be sendable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── canSendDraft: forum channel ───────────────────────────────────────────────
|
||||
|
||||
test("canSendDraft_forum_channel_returns_false", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = {
|
||||
channel: { ...RESOLVED_CHANNEL, channelType: "forum" },
|
||||
label: "#forum",
|
||||
};
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "available"),
|
||||
false,
|
||||
"draft in a forum channel must not be sendable (forum posting not wired)",
|
||||
);
|
||||
});
|
||||
|
||||
// ── canSendDraft: checking/error roots are sendable ──────────────────────────
|
||||
|
||||
test("canSendDraft_checking_root_returns_true", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "checking"),
|
||||
true,
|
||||
"checking root is optimistic — draft should still be sendable",
|
||||
);
|
||||
});
|
||||
|
||||
test("canSendDraft_error_root_returns_true", () => {
|
||||
const draft = activeDraft("chan-1");
|
||||
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "error"),
|
||||
true,
|
||||
"transport error root is optimistic — draft should still be sendable",
|
||||
);
|
||||
});
|
||||
|
||||
// ── canSendDraft: sent draft ──────────────────────────────────────────────────
|
||||
|
||||
test("canSendDraft_sent_draft_returns_false", () => {
|
||||
const draft = sentDraft("chan-1");
|
||||
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
|
||||
assert.equal(
|
||||
canSendDraft(draft, source, "available"),
|
||||
false,
|
||||
"sent draft is not sendable regardless of channel state",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,6 +62,20 @@ type MessageComposerProps = {
|
||||
containerClassName?: string;
|
||||
disabled?: boolean;
|
||||
draftKey?: string;
|
||||
/**
|
||||
* When provided, the composer fires `submitMessage` once on mount after
|
||||
* the draft matching this key has been loaded into the editor. This powers
|
||||
* the "Send message" confirm-dialog flow in the Drafts panel. The callback
|
||||
* `onAutoSubmitComplete` must clear the trigger (e.g. remove `?autoSend`
|
||||
* from the URL) — it is called synchronously before `submitMessage` fires
|
||||
* so the param is gone before any navigation the send might cause.
|
||||
*
|
||||
* Fires at most once per mount: a stable key value that persists across
|
||||
* re-renders does NOT re-fire.
|
||||
*/
|
||||
autoSubmitDraftKey?: string | null;
|
||||
/** Called when the auto-submit fires so the parent can clear the trigger. */
|
||||
onAutoSubmitComplete?: () => void;
|
||||
editTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
@@ -129,6 +143,8 @@ function MessageComposerImpl({
|
||||
containerClassName,
|
||||
disabled = false,
|
||||
draftKey,
|
||||
autoSubmitDraftKey = null,
|
||||
onAutoSubmitComplete,
|
||||
editTarget = null,
|
||||
isSending = false,
|
||||
onCancelEdit,
|
||||
@@ -634,6 +650,43 @@ function MessageComposerImpl({
|
||||
]);
|
||||
submitMessageRef.current = submitMessage;
|
||||
|
||||
// ── Auto-submit on draft send ────────────────────────────────────────────
|
||||
// When `autoSubmitDraftKey` is set (the user clicked "Send message" in the
|
||||
// Drafts panel and confirmed), fire `submitMessage` once after mount so the
|
||||
// draft is sent through the real send path (mention resolution, media, etc.).
|
||||
//
|
||||
// Guard: only fire when the effective draft key matches the trigger so a
|
||||
// stale URL param on a different channel never fires a spurious send.
|
||||
//
|
||||
// Fires at most once per mount (empty dep array after the key check) — the
|
||||
// `onAutoSubmitComplete` callback clears the trigger before `submitMessage`
|
||||
// runs, preventing re-fire on re-render or back-navigation.
|
||||
const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete);
|
||||
onAutoSubmitCompleteRef.current = onAutoSubmitComplete;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally fires once on mount only
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
autoSubmitDraftKey === null ||
|
||||
autoSubmitDraftKey !== effectiveDraftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Clear the trigger BEFORE firing so any navigation from the send cannot
|
||||
// loop back with the param still present.
|
||||
onAutoSubmitCompleteRef.current?.();
|
||||
// Defer by one macrotask so the draft-persist lifecycle effect (which runs
|
||||
// synchronously after mount) has a chance to load the draft content into
|
||||
// the Tiptap editor before we try to submit.
|
||||
const timer = window.setTimeout(() => {
|
||||
submitMessageRef.current();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount-only
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Unit tests for the auto-submit guard in MessageComposer and the
|
||||
* auto-send clear behavior in useChannelPanelHistoryState.
|
||||
*
|
||||
* Structure:
|
||||
* 1. Key-match predicate — verifies which (autoSubmitDraftKey, effectiveDraftKey)
|
||||
* pairs arm/skip the auto-submit effect in MessageComposer.
|
||||
* 2. Completion-dispatch logic — verifies that ChannelPane.handleAutoSubmitComplete
|
||||
* calls the surgical `onAutoSendComplete` callback when provided, and only
|
||||
* falls back to `goChannel` when it is absent.
|
||||
* 3. Clear-patch contract — imports and exercises `buildAutoSendClearPatch` from
|
||||
* useChannelPanelHistoryState to verify the patch removes `autoSend` but
|
||||
* leaves `thread` (and every other panel key) untouched. This is the
|
||||
* regression guard for the "auto-submit clear drops the thread route" defect:
|
||||
* if the implementation ever changes to include `thread: null` in the patch,
|
||||
* this test fails before the bug ships.
|
||||
*
|
||||
* What is NOT tested here (and why):
|
||||
* - Mounting MessageComposer: depends on Tiptap, Tauri, and React Query context
|
||||
* not available in the node:test harness. The once-only mount guard is an
|
||||
* empty-dep-array effect — verified by code review and the `goChannel`/
|
||||
* `onAutoSendComplete` dispatch test below.
|
||||
* - URL navigation: useHistorySearchState wraps @tanstack/react-router which
|
||||
* requires a browser environment. The `buildAutoSendClearPatch` pure export
|
||||
* covers the correctness of the patch without a router.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildAutoSendClearPatch } from "../../channels/ui/channelSearchKeys.ts";
|
||||
|
||||
// ── 1. Key-match predicate ────────────────────────────────────────────────────
|
||||
// Mirror the guard from MessageComposerImpl. This predicate controls whether
|
||||
// the auto-submit effect arms on mount.
|
||||
|
||||
function shouldAutoSubmit(autoSubmitDraftKey, effectiveDraftKey) {
|
||||
if (autoSubmitDraftKey === null) return false;
|
||||
if (autoSubmitDraftKey !== effectiveDraftKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
test("autoSend_main_composer_key_match_should_fire", () => {
|
||||
const channelId = "chan-abc";
|
||||
assert.equal(shouldAutoSubmit(channelId, channelId), true);
|
||||
});
|
||||
|
||||
test("autoSend_main_composer_key_mismatch_should_not_fire", () => {
|
||||
assert.equal(shouldAutoSubmit("chan-abc", "chan-xyz"), false);
|
||||
});
|
||||
|
||||
test("autoSend_thread_composer_key_match_should_fire", () => {
|
||||
const draftKey = "thread:root-111";
|
||||
assert.equal(shouldAutoSubmit(draftKey, draftKey), true);
|
||||
});
|
||||
|
||||
test("autoSend_thread_composer_key_mismatch_wrong_thread_should_not_fire", () => {
|
||||
assert.equal(shouldAutoSubmit("thread:root-aaa", "thread:root-bbb"), false);
|
||||
});
|
||||
|
||||
test("autoSend_thread_key_in_main_composer_should_not_fire", () => {
|
||||
assert.equal(shouldAutoSubmit("thread:root-111", "chan-xyz"), false);
|
||||
});
|
||||
|
||||
test("autoSend_channel_key_in_thread_composer_should_not_fire", () => {
|
||||
assert.equal(shouldAutoSubmit("chan-abc", "thread:root-111"), false);
|
||||
});
|
||||
|
||||
test("autoSend_null_trigger_should_not_fire", () => {
|
||||
assert.equal(shouldAutoSubmit(null, "chan-abc"), false);
|
||||
});
|
||||
|
||||
test("autoSend_null_trigger_thread_should_not_fire", () => {
|
||||
assert.equal(shouldAutoSubmit(null, "thread:root-111"), false);
|
||||
});
|
||||
|
||||
// ── 2. Completion-dispatch logic ─────────────────────────────────────────────
|
||||
// ChannelPane.handleAutoSubmitComplete: when onAutoSendComplete is provided it
|
||||
// must be called (surgical clear); when absent the goChannel fallback is taken.
|
||||
|
||||
function buildHandleAutoSubmitComplete({
|
||||
activeChannelId,
|
||||
goChannel,
|
||||
onAutoSendComplete = null,
|
||||
}) {
|
||||
return function handleAutoSubmitComplete() {
|
||||
if (onAutoSendComplete) {
|
||||
onAutoSendComplete();
|
||||
} else if (activeChannelId) {
|
||||
goChannel(activeChannelId, { replace: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("handleAutoSubmitComplete_calls_onAutoSendComplete_when_provided", () => {
|
||||
let surgicalClearCalled = 0;
|
||||
let goChannelCalled = 0;
|
||||
const handler = buildHandleAutoSubmitComplete({
|
||||
activeChannelId: "chan-abc",
|
||||
goChannel: () => {
|
||||
goChannelCalled++;
|
||||
},
|
||||
onAutoSendComplete: () => {
|
||||
surgicalClearCalled++;
|
||||
},
|
||||
});
|
||||
handler();
|
||||
assert.equal(
|
||||
surgicalClearCalled,
|
||||
1,
|
||||
"onAutoSendComplete should be called once",
|
||||
);
|
||||
assert.equal(
|
||||
goChannelCalled,
|
||||
0,
|
||||
"goChannel must NOT be called when onAutoSendComplete is provided",
|
||||
);
|
||||
});
|
||||
|
||||
test("handleAutoSubmitComplete_falls_back_to_goChannel_when_onAutoSendComplete_absent", () => {
|
||||
let goChannelCalled = 0;
|
||||
let lastChannelId = null;
|
||||
const handler = buildHandleAutoSubmitComplete({
|
||||
activeChannelId: "chan-abc",
|
||||
goChannel: (id) => {
|
||||
goChannelCalled++;
|
||||
lastChannelId = id;
|
||||
},
|
||||
onAutoSendComplete: null,
|
||||
});
|
||||
handler();
|
||||
assert.equal(goChannelCalled, 1, "goChannel should be called as fallback");
|
||||
assert.equal(lastChannelId, "chan-abc");
|
||||
});
|
||||
|
||||
test("handleAutoSubmitComplete_no_op_when_both_absent", () => {
|
||||
// Should not throw when neither callback nor channelId is available.
|
||||
const handler = buildHandleAutoSubmitComplete({
|
||||
activeChannelId: null,
|
||||
goChannel: () => {
|
||||
throw new Error("must not call goChannel");
|
||||
},
|
||||
onAutoSendComplete: null,
|
||||
});
|
||||
assert.doesNotThrow(() => handler());
|
||||
});
|
||||
|
||||
// ── 3. Clear-patch contract ───────────────────────────────────────────────────
|
||||
// buildAutoSendClearPatch() must remove ONLY autoSend — never thread or any
|
||||
// other panel key. This is the regression guard for the thread-route-drop defect.
|
||||
|
||||
test("buildAutoSendClearPatch_removes_autoSend", () => {
|
||||
const patch = buildAutoSendClearPatch();
|
||||
assert.equal(patch.autoSend, null, "autoSend must be null (remove from URL)");
|
||||
});
|
||||
|
||||
test("buildAutoSendClearPatch_does_not_clear_thread", () => {
|
||||
const patch = buildAutoSendClearPatch();
|
||||
assert.equal(
|
||||
patch.thread,
|
||||
undefined,
|
||||
"thread must NOT be in the patch — clearing it would unmount the thread panel",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAutoSendClearPatch_only_touches_autoSend", () => {
|
||||
const patch = buildAutoSendClearPatch();
|
||||
const keys = Object.keys(patch);
|
||||
assert.deepEqual(
|
||||
keys,
|
||||
["autoSend"],
|
||||
`patch must contain exactly [autoSend], got [${keys.join(", ")}]`,
|
||||
);
|
||||
});
|
||||
@@ -99,6 +99,15 @@ type MessageThreadPanelProps = {
|
||||
isMessageUnreadById?: (messageId: string) => boolean;
|
||||
onFollowThread?: () => void;
|
||||
onUnfollowThread?: () => void;
|
||||
/**
|
||||
* When set to `thread:<threadHead.id>`, the thread composer auto-submits
|
||||
* once on mount (Send-from-drafts flow). Must be cleared by
|
||||
* `onAutoSubmitComplete` before `submitMessage` fires so the param cannot
|
||||
* re-trigger on back-navigation.
|
||||
*/
|
||||
autoSendDraftKey?: string | null;
|
||||
/** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */
|
||||
onAutoSubmitComplete?: () => void;
|
||||
};
|
||||
|
||||
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
|
||||
@@ -329,6 +338,8 @@ export function MessageThreadPanel({
|
||||
toolbarExtraActions,
|
||||
widthPx,
|
||||
transparentChrome = false,
|
||||
autoSendDraftKey = null,
|
||||
onAutoSubmitComplete,
|
||||
}: MessageThreadPanelProps) {
|
||||
const threadBodyRef = React.useRef<HTMLDivElement>(null);
|
||||
const threadContentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -870,6 +881,8 @@ export function MessageThreadPanel({
|
||||
containerClassName={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
|
||||
disabled={disabled || isSending || !channelId}
|
||||
draftKey={`thread:${threadHead.id}`}
|
||||
autoSubmitDraftKey={autoSendDraftKey}
|
||||
onAutoSubmitComplete={onAutoSubmitComplete}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
|
||||
@@ -170,6 +170,9 @@ type E2eConfig = {
|
||||
scope_value: string;
|
||||
kinds: string; // JSON-encoded integer array, e.g. "[9,40002]"
|
||||
}>;
|
||||
// Event IDs that `get_event` should report as definitively not found.
|
||||
// Causes `useDraftRootStatus` to classify as `deleted`.
|
||||
deletedEventIds?: string[];
|
||||
};
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
@@ -7317,6 +7320,10 @@ async function handleGetEvent(
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
// Allow test specs to mark specific event IDs as definitively deleted.
|
||||
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
|
||||
throw new Error("event not found");
|
||||
}
|
||||
const knownEvents: RelayEvent[] = [
|
||||
...Array.from(mockMessages.values()).flat(),
|
||||
{
|
||||
|
||||
@@ -238,15 +238,20 @@ test.describe("drafts screenshots", () => {
|
||||
// Hover to reveal action buttons
|
||||
await textDraftRow.hover();
|
||||
|
||||
// Both action buttons should become visible on hover
|
||||
// All three action buttons should become visible on hover
|
||||
const openDraftBtn = textDraftRow.getByRole("button", {
|
||||
name: "Open draft",
|
||||
exact: true,
|
||||
});
|
||||
const sendMessageBtn = textDraftRow.getByRole("button", {
|
||||
name: "Send message",
|
||||
exact: true,
|
||||
});
|
||||
const deleteDraftBtn = textDraftRow.getByRole("button", {
|
||||
name: "Delete draft",
|
||||
});
|
||||
await expect(openDraftBtn).toBeVisible({ timeout: 4_000 });
|
||||
await expect(sendMessageBtn).toBeVisible({ timeout: 4_000 });
|
||||
await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 });
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
@@ -267,4 +272,194 @@ test.describe("drafts screenshots", () => {
|
||||
|
||||
await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` });
|
||||
});
|
||||
|
||||
test("05 — thread-draft send confirm dialog", async ({ page }) => {
|
||||
// Regression test for IMPORTANT #1: thread-reply draft Send navigates to
|
||||
// the correct channel and passes the autoSend key so the thread composer
|
||||
// (not the main composer) arms the auto-submit.
|
||||
await installMockBridge(page);
|
||||
await patchWorkspacePubkey(page);
|
||||
|
||||
// A fixed fake root event ID — in the mock bridge get_event is unhandled
|
||||
// so useDraftRootStatus will map it to `error` (not `deleted`), keeping
|
||||
// the draft sendable.
|
||||
const THREAD_ROOT_ID =
|
||||
"aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444";
|
||||
const THREAD_DRAFT_KEY = `thread:${THREAD_ROOT_ID}`;
|
||||
|
||||
await page.addInitScript(
|
||||
({ storeKey, value }) => {
|
||||
window.localStorage.setItem(storeKey, JSON.stringify(value));
|
||||
},
|
||||
{
|
||||
storeKey: DRAFT_STORE_KEY,
|
||||
value: {
|
||||
[THREAD_DRAFT_KEY]: {
|
||||
content: "Thread reply draft content",
|
||||
selectionStart: 26,
|
||||
selectionEnd: 26,
|
||||
channelId: GENERAL_CHANNEL_ID,
|
||||
createdAt: CREATED_AT_1,
|
||||
updatedAt: CREATED_AT_1,
|
||||
pendingImeta: [],
|
||||
spoileredAttachmentUrls: [],
|
||||
status: "active",
|
||||
},
|
||||
} satisfies StoredDrafts,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = await openDraftsPanel(page);
|
||||
|
||||
// The thread draft row should appear.
|
||||
const draftRow = panel.locator(
|
||||
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
|
||||
);
|
||||
await expect(draftRow).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// "Thread deleted" label must NOT appear — root status is `error` (optimistic).
|
||||
await expect(panel.getByText("Thread deleted")).not.toBeVisible();
|
||||
|
||||
// Hover to reveal the three action buttons.
|
||||
await draftRow.hover();
|
||||
const sendBtn = draftRow.getByRole("button", {
|
||||
name: "Send message",
|
||||
exact: true,
|
||||
});
|
||||
await expect(sendBtn).toBeVisible({ timeout: 4_000 });
|
||||
|
||||
// Click "Send message" — confirm dialog should appear.
|
||||
await sendBtn.click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 4_000 });
|
||||
await expect(dialog.getByText("Send message")).toBeVisible();
|
||||
// Confirm dialog body names the channel.
|
||||
await expect(dialog.getByText(/general/i)).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
await panel.screenshot({
|
||||
path: `${SHOTS}/05-thread-draft-send-dialog.png`,
|
||||
});
|
||||
|
||||
// Click Send — dialog closes and we navigate to the channel with ?autoSend.
|
||||
await dialog.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 4_000 });
|
||||
|
||||
// URL must include all three params set by DraftsPanel.handleConfirmSend:
|
||||
// ?messageId=<rootId> — scroll-targets the root message in the timeline
|
||||
// ?threadRootId=<rootId> — opens the thread panel for this root
|
||||
// ?autoSend=thread:<rootId> — arms the thread composer's once-only guard
|
||||
//
|
||||
// Integration boundary: the mock bridge does not support get_event or
|
||||
// message send, so ChannelRouteScreen.fetchRouteTargetEvents fails silently,
|
||||
// threadHeadMessage stays null, and the MessageThreadPanel (and its composer)
|
||||
// never mount. The auto-submit effect and the actual send are NOT assertable
|
||||
// in this E2E shard — they are covered by MessageComposerAutoSend.test.mjs
|
||||
// (key-match guard) and MessageComposerDraftImagePersist.test.mjs (full
|
||||
// composer mount path).
|
||||
await expect(page).toHaveURL(new RegExp(`messageId=${THREAD_ROOT_ID}`), {
|
||||
timeout: 6_000,
|
||||
});
|
||||
await expect(page).toHaveURL(new RegExp(`threadRootId=${THREAD_ROOT_ID}`));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`autoSend=${encodeURIComponent(THREAD_DRAFT_KEY)}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("06 — active-draft badge on inbox trigger and filter option", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Captures both badge placements for the PR screenshot:
|
||||
// 1. The numeric badge on the inbox filter trigger button.
|
||||
// 2. The badge next to "Drafts" in the filter dropdown.
|
||||
// Two active drafts are seeded so the count is 2.
|
||||
await installMockBridge(page);
|
||||
await patchWorkspacePubkey(page);
|
||||
await seedDraftStore(page, ACTIVE_DRAFTS);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("home-inbox")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Badge should be visible on the filter trigger with count = 2.
|
||||
const triggerBadge = page.getByTestId("inbox-draft-badge");
|
||||
await expect(triggerBadge).toBeVisible({ timeout: 6_000 });
|
||||
await expect(triggerBadge).toHaveText("2");
|
||||
|
||||
// Open the filter dropdown so the badge-option is visible too.
|
||||
await page.getByTestId("inbox-filter-trigger").click();
|
||||
const dropdownBadge = page.getByTestId("inbox-draft-badge-option");
|
||||
await expect(dropdownBadge).toBeVisible({ timeout: 4_000 });
|
||||
await expect(dropdownBadge).toHaveText("2");
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Capture the full inbox header area including the open dropdown.
|
||||
await page.getByTestId("home-inbox").screenshot({
|
||||
path: `${SHOTS}/06-draft-badge.png`,
|
||||
});
|
||||
|
||||
// Dismiss the dropdown cleanly.
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("07 — thread-deleted state (orphaned thread-reply draft)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A thread-reply draft whose root event is definitively deleted:
|
||||
// `useDraftRootStatus` maps it to `deleted`, showing the "Thread deleted"
|
||||
// label, greying the row, and disabling Open/Send.
|
||||
const DELETED_ROOT_ID =
|
||||
"dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000";
|
||||
const THREAD_DRAFT_KEY = `thread:${DELETED_ROOT_ID}`;
|
||||
|
||||
await installMockBridge(page, { deletedEventIds: [DELETED_ROOT_ID] });
|
||||
await patchWorkspacePubkey(page);
|
||||
await seedDraftStore(page, {
|
||||
[THREAD_DRAFT_KEY]: {
|
||||
content: "Planning to follow up on the discussion from last week.",
|
||||
selectionStart: 52,
|
||||
selectionEnd: 52,
|
||||
channelId: GENERAL_CHANNEL_ID,
|
||||
createdAt: CREATED_AT_1,
|
||||
updatedAt: CREATED_AT_1,
|
||||
pendingImeta: [],
|
||||
spoileredAttachmentUrls: [],
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
const panel = await openDraftsPanel(page);
|
||||
|
||||
// The orphaned draft row should render.
|
||||
const draftRow = panel.locator(
|
||||
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
|
||||
);
|
||||
await expect(draftRow).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// "Thread deleted" badge must appear once the root-status query resolves.
|
||||
const orphanLabel = panel.getByTestId(
|
||||
`home-draft-orphaned-label-${THREAD_DRAFT_KEY}`,
|
||||
);
|
||||
await expect(orphanLabel).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Hover the row to confirm Open and Send are disabled.
|
||||
await draftRow.hover();
|
||||
// Both the open and send buttons are labelled "Thread deleted" when orphaned.
|
||||
const disabledBtns = draftRow.getByRole("button", {
|
||||
name: "Thread deleted",
|
||||
});
|
||||
await expect(disabledBtns).toHaveCount(2, { timeout: 4_000 });
|
||||
// Delete is still enabled.
|
||||
await expect(
|
||||
draftRow.getByRole("button", { name: "Delete draft" }),
|
||||
).toBeVisible({ timeout: 4_000 });
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await panel.screenshot({
|
||||
path: `${SHOTS}/07-thread-deleted-state.png`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,6 +200,12 @@ type MockBridgeOptions = {
|
||||
scope_value: string;
|
||||
kinds: string; // JSON-encoded integer array, e.g. "[9,40002]"
|
||||
}>;
|
||||
/**
|
||||
* Event IDs that `get_event` should report as definitively not found.
|
||||
* Causes `useDraftRootStatus` to map the draft to `deleted` state so specs
|
||||
* can exercise the "Thread deleted" label / disabled-send path.
|
||||
*/
|
||||
deletedEventIds?: string[];
|
||||
};
|
||||
|
||||
type BridgeOptions = {
|
||||
|
||||
Reference in New Issue
Block a user