Support nested thread replies in desktop (#50)

This commit is contained in:
Wes
2026-03-13 08:38:42 -07:00
committed by GitHub
parent b9bda6b3b0
commit a516b16eda
12 changed files with 864 additions and 120 deletions
+37
View File
@@ -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,
+116 -1
View File
@@ -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
}
/>
</>
)}
+65 -7
View File
@@ -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;
}
+6
View File
@@ -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}
+32
View File
@@ -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;
+8
View File
@@ -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"
+150
View File
@@ -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],
+40
View File
@@ -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);
});