mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Support nested thread replies in desktop (#50)
This commit is contained in:
@@ -179,6 +179,14 @@ struct SearchQueryParams<'a> {
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendChannelMessageBody<'a> {
|
||||
content: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent_event_id: Option<&'a str>,
|
||||
broadcast_to_channel: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MintTokenBody<'a> {
|
||||
name: &'a str,
|
||||
@@ -274,6 +282,15 @@ pub struct SearchResponse {
|
||||
pub found: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SendChannelMessageResponse {
|
||||
pub event_id: String,
|
||||
pub parent_event_id: Option<String>,
|
||||
pub root_event_id: Option<String>,
|
||||
pub depth: u32,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -793,6 +810,25 @@ async fn search_messages(
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_channel_message(
|
||||
channel_id: String,
|
||||
content: String,
|
||||
parent_event_id: Option<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/messages");
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json(
|
||||
&SendChannelMessageBody {
|
||||
content: content.trim(),
|
||||
parent_event_id: parent_event_id.as_deref(),
|
||||
broadcast_to_channel: false,
|
||||
},
|
||||
);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
|
||||
let request = build_authed_request(
|
||||
@@ -963,6 +999,7 @@ pub fn run() {
|
||||
leave_channel,
|
||||
get_feed,
|
||||
search_messages,
|
||||
send_channel_message,
|
||||
get_event,
|
||||
list_tokens,
|
||||
mint_token,
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
collectMessageAuthorPubkeys,
|
||||
formatTimelineMessages,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getThreadReference,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import {
|
||||
usePresenceQuery,
|
||||
usePresenceSession,
|
||||
@@ -73,6 +77,7 @@ export function AppShell() {
|
||||
>(null);
|
||||
const [searchAnchorEvent, setSearchAnchorEvent] =
|
||||
React.useState<RelayEvent | null>(null);
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const profileQuery = useProfileQuery();
|
||||
@@ -91,6 +96,7 @@ export function AppShell() {
|
||||
);
|
||||
const createChannelMutation = useCreateChannelMutation();
|
||||
const activeChannel = selectedView === "channel" ? selectedChannel : null;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
const { unreadChannelIds } = useUnreadChannels(channels, activeChannel);
|
||||
const activeDmParticipantPubkeys = React.useMemo(() => {
|
||||
if (!activeChannel || activeChannel.channelType !== "dm") {
|
||||
@@ -170,6 +176,11 @@ export function AppShell() {
|
||||
resolvedMessages,
|
||||
],
|
||||
);
|
||||
const replyTargetMessage = React.useMemo(
|
||||
() =>
|
||||
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
|
||||
[replyTargetId, timelineMessages],
|
||||
);
|
||||
|
||||
const channelDescription = activeChannel
|
||||
? [
|
||||
@@ -196,6 +207,11 @@ export function AppShell() {
|
||||
const isTimelineLoading =
|
||||
messagesQuery.isLoading && resolvedMessages.length === 0;
|
||||
|
||||
const requestedAncestorIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const previousActiveChannelIdRef = React.useRef<string | null>(
|
||||
activeChannelId,
|
||||
);
|
||||
|
||||
const resolveChannel = React.useCallback(
|
||||
async (channelId: string): Promise<Channel | null> => {
|
||||
const cachedChannels =
|
||||
@@ -282,6 +298,85 @@ export function AppShell() {
|
||||
[handleOpenChannel],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousActiveChannelIdRef.current === activeChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousActiveChannelIdRef.current = activeChannelId;
|
||||
setReplyTargetId(null);
|
||||
requestedAncestorIdsRef.current.clear();
|
||||
}, [activeChannelId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (replyTargetId && !replyTargetMessage) {
|
||||
setReplyTargetId(null);
|
||||
}
|
||||
}, [replyTargetId, replyTargetMessage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeChannel || activeChannel.channelType === "forum") {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownEvents = new Map(
|
||||
resolvedMessages.map((message) => [message.id, message]),
|
||||
);
|
||||
const missingAncestorIds = new Set<string>();
|
||||
|
||||
for (const message of resolvedMessages) {
|
||||
const thread = getThreadReference(message.tags);
|
||||
|
||||
for (const eventId of [thread.parentId, thread.rootId]) {
|
||||
if (
|
||||
!eventId ||
|
||||
knownEvents.has(eventId) ||
|
||||
requestedAncestorIdsRef.current.has(eventId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missingAncestorIds.add(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingAncestorIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventId of missingAncestorIds) {
|
||||
requestedAncestorIdsRef.current.add(eventId);
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
void Promise.all(
|
||||
[...missingAncestorIds].map(async (eventId) => {
|
||||
try {
|
||||
const event = await getEventById(eventId);
|
||||
|
||||
if (
|
||||
isCancelled ||
|
||||
getChannelIdFromTags(event.tags) !== activeChannel.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
["channel-messages", activeChannel.id],
|
||||
(current = []) => mergeMessages(current, event),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load ancestor event", eventId, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [activeChannel, queryClient, resolvedMessages]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isSettingsShortcut =
|
||||
@@ -435,10 +530,11 @@ export function AppShell() {
|
||||
) : (
|
||||
<>
|
||||
<MessageTimeline
|
||||
activeReplyTargetId={replyTargetId}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
: "Messages will appear here once the relay has history for this channel."
|
||||
: "Messages and sub-replies will appear here once the relay has history for this channel."
|
||||
}
|
||||
emptyTitle={
|
||||
activeChannel
|
||||
@@ -450,6 +546,11 @@ export function AppShell() {
|
||||
isLoading={isTimelineLoading}
|
||||
key={activeChannel?.id ?? "no-channel"}
|
||||
messages={timelineMessages}
|
||||
onReply={(message) => {
|
||||
setReplyTargetId((current) =>
|
||||
current === message.id ? null : message.id,
|
||||
);
|
||||
}}
|
||||
onTargetReached={(messageId) => {
|
||||
setSearchAnchor((current) =>
|
||||
current?.eventId === messageId ? null : current,
|
||||
@@ -473,11 +574,16 @@ export function AppShell() {
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
key={activeChannel?.id ?? "no-channel"}
|
||||
onCancelReply={() => {
|
||||
setReplyTargetId(null);
|
||||
}}
|
||||
onSend={async (content, mentionPubkeys) => {
|
||||
await sendMessageMutation.mutateAsync({
|
||||
content,
|
||||
mentionPubkeys,
|
||||
parentEventId: replyTargetId,
|
||||
});
|
||||
setReplyTargetId(null);
|
||||
}}
|
||||
placeholder={
|
||||
activeChannel?.archivedAt
|
||||
@@ -490,6 +596,15 @@ export function AppShell() {
|
||||
? `Message #${activeChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
replyTarget={
|
||||
replyTargetMessage
|
||||
? {
|
||||
author: replyTargetMessage.author,
|
||||
body: replyTargetMessage.body,
|
||||
id: replyTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useEffect, useEffectEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
|
||||
import {
|
||||
buildReplyTags,
|
||||
resolveReplyRootId,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
type MessageQueryContext = {
|
||||
@@ -49,11 +54,26 @@ function createOptimisticMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
identity: Identity,
|
||||
currentMessages: RelayEvent[],
|
||||
mentionPubkeys: string[] = [],
|
||||
parentEventId: string | null = null,
|
||||
): RelayEvent {
|
||||
const tags: string[][] = [["h", channelId]];
|
||||
for (const pubkey of mentionPubkeys) {
|
||||
tags.push(["p", pubkey]);
|
||||
const tags: string[][] = [];
|
||||
|
||||
if (parentEventId) {
|
||||
tags.push(
|
||||
...buildReplyTags(
|
||||
channelId,
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, currentMessages),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
tags.push(["h", channelId]);
|
||||
for (const pubkey of mentionPubkeys) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -77,7 +97,7 @@ export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
const history = await relayClient.fetchChannelHistory(channel.id);
|
||||
const history = await relayClient.fetchChannelHistory(channel.id, 200);
|
||||
return dedupeMessagesById(history);
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
@@ -150,17 +170,53 @@ export function useSendMessageMutation(
|
||||
return useMutation<
|
||||
RelayEvent,
|
||||
Error,
|
||||
{ content: string; mentionPubkeys?: string[] },
|
||||
{
|
||||
content: string;
|
||||
mentionPubkeys?: string[];
|
||||
parentEventId?: string | null;
|
||||
},
|
||||
MessageQueryContext | undefined
|
||||
>({
|
||||
mutationFn: async ({ content, mentionPubkeys }) => {
|
||||
mutationFn: async ({ content, mentionPubkeys, parentEventId }) => {
|
||||
if (!channel || channel.channelType === "forum") {
|
||||
throw new Error("This channel does not support message sending yet.");
|
||||
}
|
||||
|
||||
if (!identity) {
|
||||
throw new Error("No identity available for sending messages.");
|
||||
}
|
||||
|
||||
if (parentEventId) {
|
||||
const cachedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>([
|
||||
"channel-messages",
|
||||
channel.id,
|
||||
]) ?? [];
|
||||
const result = await sendChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
parentEventId,
|
||||
);
|
||||
|
||||
return {
|
||||
id: result.eventId,
|
||||
pubkey: identity.pubkey,
|
||||
created_at: result.createdAt,
|
||||
kind: 4_0001,
|
||||
tags: buildReplyTags(
|
||||
channel.id,
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, cachedMessages),
|
||||
),
|
||||
content: content.trim(),
|
||||
sig: "",
|
||||
};
|
||||
}
|
||||
|
||||
return relayClient.sendMessage(channel.id, content, mentionPubkeys ?? []);
|
||||
},
|
||||
onMutate: async ({ content, mentionPubkeys }) => {
|
||||
onMutate: async ({ content, mentionPubkeys, parentEventId }) => {
|
||||
if (!channel || !identity || channel.channelType === "forum") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -174,7 +230,9 @@ export function useSendMessageMutation(
|
||||
channel.id,
|
||||
content.trim(),
|
||||
identity,
|
||||
previousMessages,
|
||||
mentionPubkeys ?? [],
|
||||
parentEventId ?? null,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
@@ -70,13 +71,71 @@ export function formatTimelineMessages(
|
||||
currentUserAvatarUrl: string | null,
|
||||
profiles?: UserProfileLookup,
|
||||
): TimelineMessage[] {
|
||||
return events.map((event) => {
|
||||
const eventsById = new Map(events.map((event) => [event.id, event]));
|
||||
const authorPubkeyByEventId = new Map<string, string>();
|
||||
const authorLabelByEventId = new Map<string, string>();
|
||||
const depthByEventId = new Map<string, number>();
|
||||
const resolvingEventIds = new Set<string>();
|
||||
|
||||
function getAuthorLabel(event: RelayEvent) {
|
||||
const cached = authorLabelByEventId.get(event.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const authorPubkey = getEffectiveAuthorPubkey(event);
|
||||
const author = formatMessageAuthor(event, channel, currentPubkey, profiles);
|
||||
|
||||
authorPubkeyByEventId.set(event.id, authorPubkey);
|
||||
authorLabelByEventId.set(event.id, author);
|
||||
return author;
|
||||
}
|
||||
|
||||
function getDepth(event: RelayEvent): number {
|
||||
const cached = depthByEventId.get(event.id);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (resolvingEventIds.has(event.id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const thread = getThreadReference(event.tags);
|
||||
if (!thread.parentId) {
|
||||
depthByEventId.set(event.id, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parent = eventsById.get(thread.parentId);
|
||||
if (!parent) {
|
||||
const fallbackDepth =
|
||||
thread.rootId && thread.rootId !== thread.parentId ? 2 : 1;
|
||||
depthByEventId.set(event.id, fallbackDepth);
|
||||
return fallbackDepth;
|
||||
}
|
||||
|
||||
resolvingEventIds.add(event.id);
|
||||
const depth = getDepth(parent) + 1;
|
||||
resolvingEventIds.delete(event.id);
|
||||
depthByEventId.set(event.id, depth);
|
||||
return depth;
|
||||
}
|
||||
|
||||
return events.map((event) => {
|
||||
const author = getAuthorLabel(event);
|
||||
const authorPubkey =
|
||||
authorPubkeyByEventId.get(event.id) ?? getEffectiveAuthorPubkey(event);
|
||||
const thread = getThreadReference(event.tags);
|
||||
const parentEvent = thread.parentId
|
||||
? eventsById.get(thread.parentId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
createdAt: event.created_at,
|
||||
pubkey: authorPubkey,
|
||||
author: formatMessageAuthor(event, channel, currentPubkey, profiles),
|
||||
author,
|
||||
avatarUrl: getAuthorAvatarUrl({
|
||||
authorPubkey,
|
||||
currentPubkey,
|
||||
@@ -88,6 +147,11 @@ export function formatTimelineMessages(
|
||||
minute: "2-digit",
|
||||
}).format(new Date(event.created_at * 1_000)),
|
||||
body: event.content,
|
||||
parentId: thread.parentId,
|
||||
rootId: thread.rootId,
|
||||
depth: getDepth(event),
|
||||
replyToAuthor: parentEvent ? getAuthorLabel(parentEvent) : null,
|
||||
replyToSnippet: parentEvent?.content ?? null,
|
||||
accent: currentPubkey === authorPubkey,
|
||||
pending: event.pending,
|
||||
kind: event.kind,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export type ThreadReference = {
|
||||
parentId: string | null;
|
||||
rootId: string | null;
|
||||
};
|
||||
|
||||
function getEventTags(tags: string[][]) {
|
||||
return tags.filter((tag) => tag[0] === "e" && typeof tag[1] === "string");
|
||||
}
|
||||
|
||||
export function getChannelIdFromTags(tags: string[][]) {
|
||||
return tags.find((tag) => tag[0] === "h")?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function getThreadReference(tags: string[][]): ThreadReference {
|
||||
const eventTags = getEventTags(tags);
|
||||
|
||||
if (eventTags.length === 0) {
|
||||
return {
|
||||
parentId: null,
|
||||
rootId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const rootTag = eventTags.find((tag) => tag[3] === "root");
|
||||
const replyTag =
|
||||
[...eventTags].reverse().find((tag) => tag[3] === "reply") ?? null;
|
||||
|
||||
if (!replyTag) {
|
||||
return {
|
||||
parentId: null,
|
||||
rootId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const parentId = replyTag[1] ?? null;
|
||||
|
||||
return {
|
||||
parentId,
|
||||
rootId: rootTag?.[1] ?? parentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReplyTags(
|
||||
channelId: string,
|
||||
authorPubkey: string,
|
||||
parentEventId: string,
|
||||
rootEventId: string,
|
||||
) {
|
||||
const tags: string[][] = [
|
||||
["p", authorPubkey],
|
||||
["h", channelId],
|
||||
];
|
||||
|
||||
if (parentEventId === rootEventId) {
|
||||
tags.push(["e", rootEventId, "", "reply"]);
|
||||
return tags;
|
||||
}
|
||||
|
||||
tags.push(["e", rootEventId, "", "root"]);
|
||||
tags.push(["e", parentEventId, "", "reply"]);
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function resolveReplyRootId(
|
||||
parentEventId: string,
|
||||
events: RelayEvent[],
|
||||
) {
|
||||
const parent = events.find((event) => event.id === parentEventId);
|
||||
if (!parent) {
|
||||
return parentEventId;
|
||||
}
|
||||
|
||||
const thread = getThreadReference(parent.tags);
|
||||
return thread.rootId ?? parent.id;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
export type TimelineMessage = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
pubkey?: string;
|
||||
author: string;
|
||||
avatarUrl?: string | null;
|
||||
role?: string;
|
||||
time: string;
|
||||
body: string;
|
||||
parentId?: string | null;
|
||||
rootId?: string | null;
|
||||
depth: number;
|
||||
replyToAuthor?: string | null;
|
||||
replyToSnippet?: string | null;
|
||||
accent?: boolean;
|
||||
pending?: boolean;
|
||||
highlighted?: boolean;
|
||||
|
||||
@@ -14,8 +14,14 @@ type MessageComposerProps = {
|
||||
channelName: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
onCancelReply?: () => void;
|
||||
onSend: (content: string, mentionPubkeys: string[]) => Promise<void>;
|
||||
placeholder?: string;
|
||||
replyTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const MAX_TEXTAREA_ROWS = 4;
|
||||
@@ -45,8 +51,10 @@ export function MessageComposer({
|
||||
channelName,
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
onCancelReply,
|
||||
onSend,
|
||||
placeholder,
|
||||
replyTarget = null,
|
||||
}: MessageComposerProps) {
|
||||
const [content, setContent] = React.useState("");
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
@@ -258,6 +266,14 @@ export function MessageComposer({
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!replyTarget || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
textareaRef.current?.focus();
|
||||
}, [disabled, replyTarget]);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border/80 bg-background p-4">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
@@ -274,6 +290,31 @@ export function MessageComposer({
|
||||
suggestions={isMentionOpen ? suggestions : []}
|
||||
/>
|
||||
|
||||
{replyTarget ? (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2"
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Replying to {replyTarget.author}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={onCancelReply}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Textarea
|
||||
aria-label="Message channel"
|
||||
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
|
||||
@@ -281,7 +322,12 @@ export function MessageComposer({
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? `Message #${channelName}`}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(replyTarget
|
||||
? `Reply to ${replyTarget.author} in #${channelName}`
|
||||
: `Message #${channelName}`)
|
||||
}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={content}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { ArrowDown, CornerUpLeft } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
@@ -18,6 +18,8 @@ type MessageTimelineProps = {
|
||||
isLoading?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
activeReplyTargetId?: string | null;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
};
|
||||
@@ -31,11 +33,69 @@ function isNearBottom(container: HTMLDivElement) {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
function MessageActionBar({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onReply,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
}) {
|
||||
if (!onReply) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReplyingToMessage = activeReplyTargetId === message.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-40 overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
|
||||
"opacity-100 translate-y-0 sm:max-w-0 sm:opacity-0 sm:translate-y-1",
|
||||
"sm:group-hover/message:max-w-40 sm:group-hover/message:opacity-100 sm:group-hover/message:translate-y-0",
|
||||
"sm:group-focus-within/message:max-w-40 sm:group-focus-within/message:opacity-100 sm:group-focus-within/message:translate-y-0",
|
||||
isReplyingToMessage
|
||||
? "sm:max-w-40 sm:opacity-100 sm:translate-y-0"
|
||||
: "",
|
||||
)}
|
||||
data-testid={`message-action-bar-${message.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="h-6 rounded-full px-2.5 text-[11px]"
|
||||
data-testid={`reply-message-${message.id}`}
|
||||
onClick={() => {
|
||||
onReply(message);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={isReplyingToMessage ? "secondary" : "ghost"}
|
||||
>
|
||||
<CornerUpLeft className="h-3 w-3" />
|
||||
{isReplyingToMessage ? "Replying" : "Reply"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onReply,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
}) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const compressedDepth = Math.max(message.depth - visibleDepth, 0);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
@@ -68,121 +128,168 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"flex gap-3 rounded-2xl px-2 py-2 transition-colors",
|
||||
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
<div
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
aria-hidden
|
||||
className="absolute bottom-2 left-3 top-2 rounded-full border-l border-border/70"
|
||||
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="truncate text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex gap-3 rounded-2xl px-2 py-2 transition-colors",
|
||||
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
activeReplyTargetId === message.id
|
||||
? "bg-muted/60 ring-1 ring-border"
|
||||
: "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0">
|
||||
{message.parentId ? (
|
||||
<div className="mb-1 flex min-w-0 flex-wrap items-center gap-2 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
Reply
|
||||
</span>
|
||||
<span className="truncate normal-case tracking-normal">
|
||||
{message.replyToAuthor
|
||||
? `to ${message.replyToAuthor}`
|
||||
: "to an earlier message"}
|
||||
</span>
|
||||
{compressedDepth > 0 ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
|
||||
+{compressedDepth} more level
|
||||
{compressedDepth === 1 ? "" : "s"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="truncate text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onReply={onReply}
|
||||
/>
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{message.replyToSnippet ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{message.replyToSnippet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{expandedDiffId === message.id && (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
{renderBody()}
|
||||
{expandedDiffId === message.id && (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,6 +317,8 @@ export function MessageTimeline({
|
||||
isLoading = false,
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
activeReplyTargetId = null,
|
||||
onReply,
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
}: MessageTimelineProps) {
|
||||
@@ -564,11 +673,13 @@ export function MessageTimeline({
|
||||
{!isLoading
|
||||
? messages.map((message) => (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
key={message.id}
|
||||
message={{
|
||||
...message,
|
||||
highlighted: message.id === highlightedMessageId,
|
||||
}}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
RelayEvent,
|
||||
SearchMessagesInput,
|
||||
SearchMessagesResponse,
|
||||
SendChannelMessageResult,
|
||||
SetPresenceResult,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
@@ -152,6 +153,14 @@ type RawSearchResponse = {
|
||||
found: number;
|
||||
};
|
||||
|
||||
type RawSendChannelMessageResult = {
|
||||
event_id: string;
|
||||
parent_event_id: string | null;
|
||||
root_event_id: string | null;
|
||||
depth: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RawToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -501,6 +510,29 @@ export async function getEventById(eventId: string): Promise<RelayEvent> {
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
export async function sendChannelMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
parentEventId?: string | null,
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
{
|
||||
channelId,
|
||||
content,
|
||||
parentEventId,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
eventId: response.event_id,
|
||||
parentEventId: response.parent_event_id,
|
||||
rootEventId: response.root_event_id,
|
||||
depth: response.depth,
|
||||
createdAt: response.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function signRelayEvent(input: {
|
||||
kind: number;
|
||||
content: string;
|
||||
|
||||
@@ -126,6 +126,14 @@ export type RelayEvent = {
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
export type SendChannelMessageResult = {
|
||||
eventId: string;
|
||||
parentEventId: string | null;
|
||||
rootEventId: string | null;
|
||||
depth: number;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type FeedItemCategory =
|
||||
| "mention"
|
||||
| "needs_action"
|
||||
|
||||
@@ -159,6 +159,14 @@ type RawSearchResponse = {
|
||||
found: number;
|
||||
};
|
||||
|
||||
type RawSendChannelMessageResponse = {
|
||||
event_id: string;
|
||||
parent_event_id: string | null;
|
||||
root_event_id: string | null;
|
||||
depth: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RawToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -826,6 +834,35 @@ function getChannelIdFromTags(tags: string[][]): string | undefined {
|
||||
return tags.find((tag) => tag[0] === "h")?.[1];
|
||||
}
|
||||
|
||||
function getThreadReferenceFromTags(tags: string[][]) {
|
||||
const eventTags = tags.filter(
|
||||
(tag) => tag[0] === "e" && typeof tag[1] === "string",
|
||||
);
|
||||
|
||||
if (eventTags.length === 0) {
|
||||
return {
|
||||
parentEventId: null,
|
||||
rootEventId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const rootTag = eventTags.find((tag) => tag[3] === "root");
|
||||
const replyTag =
|
||||
[...eventTags].reverse().find((tag) => tag[3] === "reply") ?? null;
|
||||
|
||||
if (!replyTag) {
|
||||
return {
|
||||
parentEventId: null,
|
||||
rootEventId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parentEventId: replyTag[1] ?? null,
|
||||
rootEventId: rootTag?.[1] ?? replyTag[1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
const existing = mockMessages.get(channelId);
|
||||
if (existing) {
|
||||
@@ -1873,6 +1910,114 @@ async function handleSearchMessages(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function handleSendChannelMessage(
|
||||
args: {
|
||||
channelId: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawSendChannelMessageResponse> {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!args.parentEventId) {
|
||||
const event = createMockEvent(40001, args.content, [
|
||||
["h", args.channelId],
|
||||
]);
|
||||
recordMockMessage(args.channelId, event);
|
||||
emitMockLiveEvent(args.channelId, event);
|
||||
|
||||
return {
|
||||
event_id: event.id,
|
||||
parent_event_id: null,
|
||||
root_event_id: null,
|
||||
depth: 0,
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
const history = getMockMessageStore(args.channelId);
|
||||
const parentEvent = history.find(
|
||||
(event) => event.id === args.parentEventId,
|
||||
);
|
||||
const parentThread = parentEvent
|
||||
? getThreadReferenceFromTags(parentEvent.tags)
|
||||
: {
|
||||
parentEventId: null,
|
||||
rootEventId: null,
|
||||
};
|
||||
const rootEventId = parentThread.rootEventId ?? args.parentEventId;
|
||||
const depth = parentEvent
|
||||
? (() => {
|
||||
let currentEvent: RelayEvent | undefined = parentEvent;
|
||||
let nextDepth = 1;
|
||||
|
||||
while (currentEvent) {
|
||||
const reference = getThreadReferenceFromTags(currentEvent.tags);
|
||||
if (!reference.parentEventId) {
|
||||
return nextDepth;
|
||||
}
|
||||
|
||||
nextDepth += 1;
|
||||
currentEvent = history.find(
|
||||
(event) => event.id === reference.parentEventId,
|
||||
);
|
||||
}
|
||||
|
||||
return nextDepth;
|
||||
})()
|
||||
: 1;
|
||||
|
||||
const event: RelayEvent = {
|
||||
id: crypto.randomUUID().replace(/-/g, ""),
|
||||
pubkey: getMockMemberPubkey(config),
|
||||
created_at: createdAt,
|
||||
kind: 40001,
|
||||
tags:
|
||||
rootEventId === args.parentEventId
|
||||
? [
|
||||
["p", getMockMemberPubkey(config)],
|
||||
["h", args.channelId],
|
||||
["e", rootEventId, "", "reply"],
|
||||
]
|
||||
: [
|
||||
["p", getMockMemberPubkey(config)],
|
||||
["h", args.channelId],
|
||||
["e", rootEventId, "", "root"],
|
||||
["e", args.parentEventId, "", "reply"],
|
||||
],
|
||||
content: args.content.trim(),
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
};
|
||||
|
||||
recordMockMessage(args.channelId, event);
|
||||
emitMockLiveEvent(args.channelId, event);
|
||||
|
||||
return {
|
||||
event_id: event.id,
|
||||
parent_event_id: args.parentEventId,
|
||||
root_event_id: rootEventId,
|
||||
depth,
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawSendChannelMessageResponse>(
|
||||
config,
|
||||
`/api/channels/${args.channelId}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
content: args.content,
|
||||
parent_event_id: args.parentEventId,
|
||||
broadcast_to_channel: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetEvent(
|
||||
args: {
|
||||
eventId: string;
|
||||
@@ -2246,6 +2391,11 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleSearchMessages>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "send_channel_message":
|
||||
return handleSendChannelMessage(
|
||||
payload as Parameters<typeof handleSendChannelMessage>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_event":
|
||||
return handleGetEvent(
|
||||
payload as Parameters<typeof handleGetEvent>[0],
|
||||
|
||||
@@ -191,3 +191,43 @@ test("shows your avatar on your own message when profile avatar is set", async (
|
||||
avatarUrl,
|
||||
);
|
||||
});
|
||||
|
||||
test("supports nested replies with visible indentation", async ({ page }) => {
|
||||
const firstReply = `First threaded reply ${Date.now()}`;
|
||||
const nestedReply = `Nested threaded reply ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"Welcome to #general",
|
||||
);
|
||||
|
||||
const rows = page.getByTestId("message-row");
|
||||
const replyButtons = page.locator('[data-testid^="reply-message-"]');
|
||||
|
||||
await rows.first().hover();
|
||||
await replyButtons.first().click();
|
||||
await expect(page.getByTestId("reply-target")).toContainText("Replying to");
|
||||
await page.getByTestId("message-input").fill(firstReply);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(rows.last()).toContainText(firstReply);
|
||||
|
||||
await rows.last().hover();
|
||||
await replyButtons.last().click();
|
||||
await expect(page.getByTestId("reply-target")).toContainText(firstReply);
|
||||
await page.getByTestId("message-input").fill(nestedReply);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(rows.last()).toContainText(nestedReply);
|
||||
|
||||
const rootBox = await rows.nth(0).boundingBox();
|
||||
const firstReplyBox = await rows.nth(1).boundingBox();
|
||||
const nestedReplyBox = await rows.nth(2).boundingBox();
|
||||
|
||||
if (!rootBox || !firstReplyBox || !nestedReplyBox) {
|
||||
throw new Error("Expected reply rows to be rendered.");
|
||||
}
|
||||
|
||||
expect(firstReplyBox.x).toBeGreaterThan(rootBox.x + 8);
|
||||
expect(nestedReplyBox.x).toBeGreaterThan(firstReplyBox.x + 8);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user