mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
734 lines
23 KiB
TypeScript
734 lines
23 KiB
TypeScript
import { useEffect, useEffectEvent } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
channelMessagesKey,
|
|
channelWindowKey,
|
|
threadRepliesKey,
|
|
} from "@/features/messages/lib/messageQueryKeys";
|
|
import {
|
|
buildReplyTags,
|
|
getThreadReference,
|
|
isBroadcastReply,
|
|
normalizeMentionPubkeys,
|
|
resolveReplyRootId,
|
|
} from "@/features/messages/lib/threading";
|
|
import {
|
|
projectChannelWindowMessages,
|
|
refreshChannelWindowMessages,
|
|
} from "@/features/messages/lib/projectChannelWindow";
|
|
import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation";
|
|
import {
|
|
mergeMessages,
|
|
mergeTimelineCacheMessages,
|
|
} from "@/features/messages/lib/messageMerge";
|
|
|
|
export { mergeMessages, mergeTimelineCacheMessages };
|
|
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
|
|
import {
|
|
clearTimeoutState,
|
|
recordTimeoutFromRejection,
|
|
} from "@/features/moderation/lib/timeoutStore";
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import { customEmojiQueryKey } from "@/features/custom-emoji/hooks";
|
|
import { channelsQueryKey } from "@/features/channels/hooks";
|
|
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
|
|
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
|
import {
|
|
addReaction,
|
|
deleteMessage,
|
|
editMessage,
|
|
removeReaction,
|
|
sendChannelMessage,
|
|
} from "@/shared/api/tauri";
|
|
import { getChannelWindowEvents } from "@/shared/api/channelWindow";
|
|
import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
|
|
// Same .mjs the renderer uses, so the cache-update projection can't drift
|
|
// from the on-render overlay.
|
|
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
|
|
import {
|
|
emptyChannelWindowStore,
|
|
mapChannelWindowEvents,
|
|
mergeLiveChannelWindowEvent,
|
|
mergeLiveThreadSummary,
|
|
replaceNewestChannelWindow,
|
|
type ChannelWindowStore,
|
|
} from "@/features/messages/lib/channelWindowStore";
|
|
import {
|
|
parseChannelWindowResponse,
|
|
parseLiveThreadSummary,
|
|
} from "@/features/messages/lib/channelWindowResponse";
|
|
import {
|
|
CHANNEL_AUX_EVENT_KINDS,
|
|
CHANNEL_TIMELINE_CONTENT_KINDS,
|
|
KIND_CHANNEL_THREAD_SUMMARY,
|
|
KIND_STREAM_MESSAGE,
|
|
KIND_SYSTEM_MESSAGE,
|
|
} from "@/shared/constants/kinds";
|
|
|
|
type MessageQueryContext = {
|
|
optimisticId: string;
|
|
previousMessages: RelayEvent[];
|
|
previousWindow: ChannelWindowStore | undefined;
|
|
channelId: string;
|
|
queryKey: ReturnType<typeof channelMessagesKey>;
|
|
};
|
|
|
|
const CHANNEL_TIMELINE_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);
|
|
const CHANNEL_AUX_KINDS = new Set<number>(CHANNEL_AUX_EVENT_KINDS);
|
|
|
|
export function createOptimisticMessage(
|
|
channelId: string,
|
|
content: string,
|
|
identity: Identity,
|
|
currentMessages: RelayEvent[],
|
|
mentionPubkeys: string[] = [],
|
|
parentEventId: string | null = null,
|
|
mediaTags: string[][] = [],
|
|
): RelayEvent {
|
|
const localKey = `optimistic-${crypto.randomUUID()}`;
|
|
const tags: string[][] = [];
|
|
|
|
if (parentEventId) {
|
|
tags.push(
|
|
...buildReplyTags(
|
|
channelId,
|
|
identity.pubkey,
|
|
parentEventId,
|
|
resolveReplyRootId(parentEventId, currentMessages),
|
|
mentionPubkeys,
|
|
),
|
|
);
|
|
} else {
|
|
tags.push(["h", channelId]);
|
|
tags.push(["p", identity.pubkey]);
|
|
for (const pubkey of normalizeMentionPubkeys(
|
|
mentionPubkeys,
|
|
identity.pubkey,
|
|
)) {
|
|
tags.push(["p", pubkey]);
|
|
}
|
|
}
|
|
|
|
for (const tag of mediaTags) {
|
|
tags.push(tag);
|
|
}
|
|
|
|
return {
|
|
id: localKey,
|
|
localKey,
|
|
pubkey: identity.pubkey,
|
|
created_at: Math.floor(Date.now() / 1_000),
|
|
kind: KIND_STREAM_MESSAGE,
|
|
tags,
|
|
content,
|
|
sig: "",
|
|
pending: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolves the effective target channel for a send operation.
|
|
*
|
|
* When `capturedChannelId` is supplied (non-null), the target is looked up from
|
|
* `channelsCache` — this pins the send to the compose-time channel regardless
|
|
* of any subsequent navigation. If the id is supplied but resolves to nothing,
|
|
* returns `null` (caller should throw — don't silently fall back to the live
|
|
* channel). When `capturedChannelId` is null, the caller didn't capture one and
|
|
* the closed-over `fallbackChannel` is the intended target.
|
|
*
|
|
* Exported for unit testing.
|
|
*/
|
|
export function resolveEffectiveChannel(
|
|
capturedChannelId: string | null | undefined,
|
|
channelsCache: Channel[] | undefined,
|
|
fallbackChannel: Channel | null,
|
|
): Channel | null {
|
|
if (capturedChannelId == null) {
|
|
return fallbackChannel;
|
|
}
|
|
return channelsCache?.find((c) => c.id === capturedChannelId) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolves a send target captured as either the channel object itself or its id.
|
|
* A relay-returned channel remains authoritative even when the shared channel
|
|
* list is temporarily stale and does not contain it.
|
|
*
|
|
* Exported for unit testing.
|
|
*/
|
|
export function resolveSendChannel(
|
|
targetChannel: Channel | undefined,
|
|
capturedChannelId: string | null | undefined,
|
|
channelsCache: Channel[] | undefined,
|
|
fallbackChannel: Channel | null,
|
|
): Channel | null {
|
|
return (
|
|
targetChannel ??
|
|
resolveEffectiveChannel(capturedChannelId, channelsCache, fallbackChannel)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolves the thread reply target from a submit-time captured context or,
|
|
* for callers that predate the capture pattern, from live refs.
|
|
*
|
|
* When `threadContext` is supplied (non-null), its values are used exclusively
|
|
* — no live-ref reads occur. This is the race-free path: the context was
|
|
* captured synchronously at submit time before any async awaits.
|
|
*
|
|
* When `threadContext` is null/undefined (legacy callers), falls back to
|
|
* `liveReplyTargetId ?? liveThreadHeadId`.
|
|
*
|
|
* Returns null when no parentEventId can be resolved (caller should bail).
|
|
*/
|
|
export function resolveThreadReplyTarget(
|
|
threadContext:
|
|
| { parentEventId: string | null; threadHeadId: string | null }
|
|
| null
|
|
| undefined,
|
|
liveReplyTargetId: string | null | undefined,
|
|
liveThreadHeadId: string | null | undefined,
|
|
): { parentEventId: string; threadHeadId: string | null } | null {
|
|
if (threadContext != null) {
|
|
// Captured context: use exclusively — no ?? fallback to live refs.
|
|
if (!threadContext.parentEventId) {
|
|
return null;
|
|
}
|
|
return {
|
|
parentEventId: threadContext.parentEventId,
|
|
threadHeadId: threadContext.threadHeadId,
|
|
};
|
|
}
|
|
// Legacy path: read from live refs.
|
|
const parentEventId = liveReplyTargetId ?? liveThreadHeadId ?? null;
|
|
if (!parentEventId) {
|
|
return null;
|
|
}
|
|
return {
|
|
parentEventId,
|
|
threadHeadId: liveThreadHeadId ?? null,
|
|
};
|
|
}
|
|
|
|
export function useChannelWindowQuery(channel: Channel | null) {
|
|
const queryClient = useQueryClient();
|
|
const queryKey = channelWindowKey(channel?.id ?? "none");
|
|
return useQuery({
|
|
enabled: channel !== null && channel.channelType !== "forum",
|
|
queryKey,
|
|
queryFn: () =>
|
|
queryClient.getQueryData<ChannelWindowStore>(queryKey) ??
|
|
emptyChannelWindowStore(),
|
|
staleTime: Number.POSITIVE_INFINITY,
|
|
});
|
|
}
|
|
|
|
export function useChannelMessagesQuery(channel: Channel | null) {
|
|
const queryClient = useQueryClient();
|
|
const queryKey = channelMessagesKey(channel?.id ?? "none");
|
|
const windowKey = channelWindowKey(channel?.id ?? "none");
|
|
|
|
return useQuery({
|
|
enabled: channel !== null && channel.channelType !== "forum",
|
|
queryKey,
|
|
queryFn: async () => {
|
|
if (!channel) throw new Error("No channel selected.");
|
|
const previousMessages =
|
|
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
|
const events = await getChannelWindowEvents(channel.id);
|
|
const page = parseChannelWindowResponse(events, channel.id, null);
|
|
const current =
|
|
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
|
|
emptyChannelWindowStore();
|
|
const next = replaceNewestChannelWindow(current, page);
|
|
queryClient.setQueryData(windowKey, next);
|
|
return reconcileChannelWindowMessages(next, previousMessages);
|
|
},
|
|
staleTime: 5 * 60 * 1_000,
|
|
gcTime: 60 * 60 * 1_000,
|
|
});
|
|
}
|
|
|
|
export function useChannelSubscription(channel: Channel | null) {
|
|
const queryClient = useQueryClient();
|
|
const channelId = channel?.id ?? null;
|
|
const channelType = channel?.channelType ?? null;
|
|
const refreshNewestWindow = useEffectEvent(async () => {
|
|
if (!channelId) return;
|
|
await refreshChannelWindowMessages(queryClient, channelId);
|
|
});
|
|
|
|
const appendMessage = useEffectEvent((event: RelayEvent) => {
|
|
if (!channelId) return;
|
|
if (event.kind === KIND_CHANNEL_THREAD_SUMMARY) {
|
|
// Relay-pushed live badge recount — window-store overlay only, never a
|
|
// timeline row (mirrors the page path, where 39005 is metadata).
|
|
const parsed = parseLiveThreadSummary(event);
|
|
if (!parsed) return;
|
|
const windowKey = channelWindowKey(channelId);
|
|
const current =
|
|
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
|
|
emptyChannelWindowStore();
|
|
const next = mergeLiveThreadSummary(current, parsed.rootId, parsed.live);
|
|
if (next !== current) queryClient.setQueryData(windowKey, next);
|
|
return;
|
|
}
|
|
const isTimelineRow = CHANNEL_TIMELINE_KINDS.has(event.kind);
|
|
const threadReference = isTimelineRow
|
|
? getThreadReference(event.tags)
|
|
: null;
|
|
if (threadReference?.parentId != null) {
|
|
const rootId = threadReference?.rootId;
|
|
if (rootId) {
|
|
queryClient.setQueryData<RelayEvent[]>(
|
|
threadRepliesKey(channelId, rootId),
|
|
(current = []) => mergeMessages(current, event),
|
|
);
|
|
}
|
|
if (!isBroadcastReply(event.tags)) return;
|
|
}
|
|
if (!isTimelineRow && !CHANNEL_AUX_KINDS.has(event.kind)) return;
|
|
if (!isTimelineRow) {
|
|
queryClient.setQueriesData<RelayEvent[]>(
|
|
{ queryKey: ["thread-replies", channelId] },
|
|
(current = []) => mergeMessages(current, event),
|
|
);
|
|
}
|
|
|
|
const windowKey = channelWindowKey(channelId);
|
|
const current =
|
|
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
|
|
emptyChannelWindowStore();
|
|
const next = mergeLiveChannelWindowEvent(current, event, isTimelineRow);
|
|
if (next !== current) {
|
|
queryClient.setQueryData(windowKey, next);
|
|
projectChannelWindowMessages(queryClient, channelId);
|
|
}
|
|
|
|
if (event.kind === KIND_SYSTEM_MESSAGE) {
|
|
try {
|
|
const payload = JSON.parse(event.content) as { type?: string };
|
|
if (
|
|
payload.type === "member_joined" ||
|
|
payload.type === "member_left" ||
|
|
payload.type === "member_removed"
|
|
) {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["channels", channelId, "members"],
|
|
});
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
exact: true,
|
|
});
|
|
}
|
|
} catch {
|
|
// Non-JSON system message — ignore.
|
|
}
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!channelId || channelType === "forum") {
|
|
return;
|
|
}
|
|
|
|
let isDisposed = false;
|
|
let cleanup: (() => Promise<void>) | undefined;
|
|
const disposeReconnectListener = relayClient.subscribeToReconnects(() => {
|
|
void refreshNewestWindow().catch((error) => {
|
|
if (!isDisposed) {
|
|
console.error(
|
|
"Failed to refresh channel window after reconnecting",
|
|
channelId,
|
|
error,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
relayClient
|
|
.subscribeToChannelLive(channelId, (event) => {
|
|
if (!isDisposed) {
|
|
appendMessage(event);
|
|
}
|
|
})
|
|
.then((dispose) => {
|
|
if (isDisposed) {
|
|
void dispose();
|
|
return;
|
|
}
|
|
|
|
cleanup = dispose;
|
|
void refreshNewestWindow().catch((error) => {
|
|
if (!isDisposed) {
|
|
console.error(
|
|
"Failed to refresh channel window after subscribing",
|
|
channelId,
|
|
error,
|
|
);
|
|
}
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to subscribe to channel", channelId, error);
|
|
});
|
|
|
|
return () => {
|
|
isDisposed = true;
|
|
disposeReconnectListener();
|
|
if (cleanup) {
|
|
void cleanup();
|
|
}
|
|
};
|
|
}, [channelId, channelType]);
|
|
}
|
|
|
|
export function useSendMessageMutation(
|
|
channel: Channel | null,
|
|
identity: Identity | undefined,
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<
|
|
RelayEvent,
|
|
Error,
|
|
{
|
|
channelId?: string;
|
|
targetChannel?: Channel;
|
|
content: string;
|
|
mentionPubkeys?: string[];
|
|
parentEventId?: string | null;
|
|
mediaTags?: string[][];
|
|
},
|
|
MessageQueryContext | undefined
|
|
>({
|
|
mutationFn: async ({
|
|
channelId: capturedChannelId,
|
|
targetChannel,
|
|
content,
|
|
mentionPubkeys,
|
|
parentEventId,
|
|
mediaTags,
|
|
}) => {
|
|
// Prefer a channel captured by the caller at compose time. Otherwise,
|
|
// resolve a captured id from the shared channel cache so navigation
|
|
// cannot redirect the message. Legacy callers without either value use
|
|
// the closed-over `channel`.
|
|
const effectiveChannel = resolveSendChannel(
|
|
targetChannel,
|
|
capturedChannelId,
|
|
queryClient.getQueryData<Channel[]>(channelsQueryKey),
|
|
channel,
|
|
);
|
|
|
|
if (effectiveChannel == null) {
|
|
if (capturedChannelId != null) {
|
|
throw new Error("Channel is no longer available.");
|
|
}
|
|
throw new Error("This channel does not support message sending yet.");
|
|
}
|
|
|
|
if (effectiveChannel.channelType === "forum") {
|
|
throw new Error("This channel does not support message sending yet.");
|
|
}
|
|
|
|
if (!identity) {
|
|
throw new Error("No identity available for sending messages.");
|
|
}
|
|
|
|
// `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30
|
|
// emoji). Split it so each kind goes to its own validated Tauri arg —
|
|
// emoji tags must NOT ride the imeta-only `media` channel (that gate
|
|
// rejects any non-imeta prefix, which silently dropped emoji sends).
|
|
const {
|
|
mediaTags: imetaTags,
|
|
emojiTags,
|
|
mentionTags,
|
|
} = splitOutgoingTags(mediaTags);
|
|
|
|
// Messages carrying media OR custom-emoji tags MUST go through REST so
|
|
// the relay's tag validation runs. The WebSocket path emits no extra
|
|
// tags, so emoji-only messages would otherwise lose their emoji tag.
|
|
if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) {
|
|
const cachedMessages =
|
|
queryClient.getQueryData<RelayEvent[]>(
|
|
channelMessagesKey(effectiveChannel.id),
|
|
) ?? [];
|
|
const result = await sendChannelMessage(
|
|
effectiveChannel.id,
|
|
content,
|
|
parentEventId ?? null,
|
|
imetaTags,
|
|
mentionPubkeys,
|
|
undefined,
|
|
emojiTags,
|
|
mentionTags,
|
|
);
|
|
|
|
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji.
|
|
// For replies, buildReplyTags already includes ["p", author] and ["h", channel].
|
|
// For non-replies (media-only), we add them ourselves.
|
|
const replyTags = parentEventId
|
|
? buildReplyTags(
|
|
effectiveChannel.id,
|
|
identity.pubkey,
|
|
parentEventId,
|
|
resolveReplyRootId(parentEventId, cachedMessages),
|
|
mentionPubkeys,
|
|
)
|
|
: [];
|
|
const baseTags = parentEventId
|
|
? replyTags // buildReplyTags includes h + author p + mention ps
|
|
: [
|
|
["h", effectiveChannel.id],
|
|
["p", identity.pubkey],
|
|
]; // non-reply: add ourselves
|
|
|
|
return {
|
|
id: result.eventId,
|
|
pubkey: identity.pubkey,
|
|
created_at: result.createdAt,
|
|
kind: KIND_STREAM_MESSAGE,
|
|
tags: [
|
|
...baseTags,
|
|
// For non-replies, add mention p-tags here (replies get them via buildReplyTags)
|
|
...(!parentEventId
|
|
? normalizeMentionPubkeys(
|
|
mentionPubkeys ?? [],
|
|
identity.pubkey,
|
|
).map((pk) => ["p", pk])
|
|
: []),
|
|
...imetaTags,
|
|
...emojiTags,
|
|
...mentionTags,
|
|
],
|
|
content: content.trim(),
|
|
sig: "",
|
|
};
|
|
}
|
|
|
|
return relayClient.sendMessage(
|
|
effectiveChannel.id,
|
|
content,
|
|
mentionPubkeys ?? [],
|
|
mentionTags,
|
|
);
|
|
},
|
|
onMutate: async ({
|
|
channelId: capturedChannelId,
|
|
targetChannel,
|
|
content,
|
|
mentionPubkeys,
|
|
parentEventId,
|
|
mediaTags,
|
|
}) => {
|
|
// Mirror mutationFn's target resolution so the optimistic message lands
|
|
// in the cache for the same channel as the real send. A caller-supplied
|
|
// channel remains valid even when a stale channel-list read omitted it.
|
|
const effectiveChannel = resolveSendChannel(
|
|
targetChannel,
|
|
capturedChannelId,
|
|
queryClient.getQueryData<Channel[]>(channelsQueryKey),
|
|
channel,
|
|
);
|
|
|
|
if (
|
|
!effectiveChannel ||
|
|
!identity ||
|
|
effectiveChannel.channelType === "forum"
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const queryKey = channelMessagesKey(effectiveChannel.id);
|
|
await queryClient.cancelQueries({ queryKey });
|
|
|
|
const previousMessages =
|
|
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
|
const windowKey = channelWindowKey(effectiveChannel.id);
|
|
const previousWindow =
|
|
queryClient.getQueryData<ChannelWindowStore>(windowKey);
|
|
const optimisticMessage = createOptimisticMessage(
|
|
effectiveChannel.id,
|
|
content.trim(),
|
|
identity,
|
|
previousMessages,
|
|
mentionPubkeys ?? [],
|
|
parentEventId ?? null,
|
|
mediaTags ?? [],
|
|
);
|
|
|
|
const nextWindow = mergeLiveChannelWindowEvent(
|
|
previousWindow ?? emptyChannelWindowStore(),
|
|
optimisticMessage,
|
|
);
|
|
queryClient.setQueryData(windowKey, nextWindow);
|
|
projectChannelWindowMessages(queryClient, effectiveChannel.id);
|
|
|
|
return {
|
|
optimisticId: optimisticMessage.id,
|
|
previousMessages,
|
|
previousWindow,
|
|
channelId: effectiveChannel.id,
|
|
queryKey,
|
|
};
|
|
},
|
|
onError: (error, _variables, context) => {
|
|
// A community timeout surfaces here as the relay's `OK false` reason.
|
|
// Record it so the composer can show the timeout chip and block further
|
|
// sends until it expires; other errors fall through to the caller.
|
|
recordTimeoutFromRejection(error?.message);
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
queryClient.setQueryData(context.queryKey, context.previousMessages);
|
|
queryClient.setQueryData(
|
|
channelWindowKey(context.channelId),
|
|
context.previousWindow,
|
|
);
|
|
},
|
|
onSuccess: (message, _variables, context) => {
|
|
// An accepted send proves the write-block is lifted; clear any recorded
|
|
// timeout so the chip and disable state fall away immediately.
|
|
clearTimeoutState();
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
const windowKey = channelWindowKey(context.channelId);
|
|
const current =
|
|
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
|
|
emptyChannelWindowStore();
|
|
const withoutPending: ChannelWindowStore = {
|
|
...current,
|
|
liveOverlay: current.liveOverlay.filter(
|
|
(event) => event.id !== context.optimisticId,
|
|
),
|
|
};
|
|
const next = mergeLiveChannelWindowEvent(withoutPending, {
|
|
...message,
|
|
localKey: context.optimisticId,
|
|
});
|
|
queryClient.setQueryData(windowKey, next);
|
|
projectChannelWindowMessages(queryClient, context.channelId);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useToggleReactionMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation<
|
|
void,
|
|
Error,
|
|
{
|
|
eventId: string;
|
|
emoji: string;
|
|
remove: boolean;
|
|
}
|
|
>({
|
|
mutationFn: async ({ eventId, emoji, remove }) => {
|
|
if (remove) {
|
|
await removeReaction(eventId, emoji);
|
|
return;
|
|
}
|
|
|
|
// Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL
|
|
// from the cached community palette so the kind:7 carries the NIP-30
|
|
// `["emoji", shortcode, url]` tag. Unicode reactions resolve to no URL.
|
|
const emojiUrl = reactionEmojiUrl(
|
|
emoji,
|
|
queryClient.getQueryData<CustomEmoji[]>(customEmojiQueryKey),
|
|
);
|
|
await addReaction(eventId, emoji, emojiUrl);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteMessageMutation(channel: Channel | null) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<void, Error, { eventId: string }>({
|
|
mutationFn: async ({ eventId }) => {
|
|
if (!channel) {
|
|
throw new Error("No channel selected.");
|
|
}
|
|
await deleteMessage(channel.id, eventId);
|
|
},
|
|
onSuccess: (_data, { eventId }) => {
|
|
if (!channel) return;
|
|
queryClient.setQueryData<RelayEvent[]>(
|
|
channelMessagesKey(channel.id),
|
|
(current = []) => current.filter((message) => message.id !== eventId),
|
|
);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to delete message: ${error.message}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useEditMessageMutation(channel: Channel | null) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<
|
|
void,
|
|
Error,
|
|
{
|
|
eventId: string;
|
|
content: string;
|
|
mediaTags?: string[][];
|
|
}
|
|
>({
|
|
mutationFn: async ({ eventId, content, mediaTags }) => {
|
|
if (!channel) {
|
|
throw new Error("No channel selected.");
|
|
}
|
|
|
|
// `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji).
|
|
// Split so each rides its own validated Tauri arg — emoji tags must NOT
|
|
// go through the imeta-only `mediaTags` channel (the Rust `imeta_tags`
|
|
// guard rejects any non-imeta prefix), mirroring the send path.
|
|
const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags);
|
|
|
|
await editMessage(channel.id, eventId, content, imetaTags, emojiTags);
|
|
},
|
|
onSuccess: (_data, { eventId, content, mediaTags }) => {
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
|
|
// Apply-on-success cache update: reflect the edit's new content and
|
|
// imeta tag set immediately, so the local cache matches what the
|
|
// receiver overlay (formatTimelineMessages) will produce when the
|
|
// edit event arrives back from the relay. (Not a true optimistic
|
|
// update — runs in onSuccess, not onMutate. Worth bearing the cost
|
|
// only because the edit event round-trip can lag perceptibly.)
|
|
const applyEdit = (message: RelayEvent): RelayEvent => {
|
|
if (message.id !== eventId) return message;
|
|
const nextTags = mediaTags
|
|
? applyEditTagOverlay(message.tags, mediaTags)
|
|
: message.tags;
|
|
return { ...message, content, tags: nextTags };
|
|
};
|
|
|
|
// The WINDOW STORE is the source of truth: every live merge
|
|
// re-flattens it over `channelMessagesKey`, so patching only the
|
|
// flattened array gets reverted by the next live event (see
|
|
// mapChannelWindowEvents). Update the store first, then keep the
|
|
// flattened cache in step for immediate paint.
|
|
queryClient.setQueryData<ChannelWindowStore>(
|
|
channelWindowKey(channel.id),
|
|
(current) =>
|
|
current ? mapChannelWindowEvents(current, applyEdit) : current,
|
|
);
|
|
queryClient.setQueryData<RelayEvent[]>(
|
|
channelMessagesKey(channel.id),
|
|
(current = []) => current.map(applyEdit),
|
|
);
|
|
},
|
|
});
|
|
}
|