feat(desktop): add #channel link rendering and autocomplete (#135)

This commit is contained in:
Wes
2026-03-20 15:19:13 -07:00
committed by GitHub
parent 107bf5a953
commit 1f37f7ea52
10 changed files with 1052 additions and 744 deletions
Generated
+299 -318
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@ sha2 = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
axum = { workspace = true }
s3 = { version = "0.35", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
infer = "0.16"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
blurhash = "0.2"
+261 -249
View File
@@ -50,6 +50,7 @@ import { relayClient } from "@/shared/api/relayClient";
import { getEventById, joinChannel } from "@/shared/api/tauri";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import {
SidebarInset,
SidebarProvider,
@@ -58,7 +59,6 @@ import {
type AppView = "home" | "channel" | "settings" | "agents";
type MainView = Exclude<AppView, "settings">;
export function AppShell() {
const [selectedView, setSelectedView] = React.useState<AppView>("home");
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
@@ -217,6 +217,14 @@ export function AppShell() {
.filter((value) => value && value.trim().length > 0)
.join(" ") || "Channel details and activity."
: "Connect to the relay to browse channels and read messages.";
const contentPaneKey =
selectedView === "home"
? "home"
: selectedView === "agents"
? "agents"
: selectedView === "settings"
? "settings"
: `channel:${activeChannel?.id ?? "none"}`;
const shouldLoadTimeline =
activeChannel !== null && activeChannel.channelType !== "forum";
const isTimelineLoading =
@@ -357,7 +365,6 @@ export function AppShell() {
isCancelled = true;
};
}, []);
React.useEffect(() => {
if (previousActiveChannelIdRef.current === activeChannelId) {
return;
@@ -367,7 +374,6 @@ export function AppShell() {
setReplyTargetId(null);
requestedAncestorIdsRef.current.clear();
}, [activeChannelId]);
React.useEffect(() => {
if (selectedView === "settings") {
return;
@@ -375,13 +381,11 @@ export function AppShell() {
lastNonSettingsViewRef.current = selectedView;
}, [selectedView]);
React.useEffect(() => {
if (replyTargetId && !replyTargetMessage) {
setReplyTargetId(null);
}
}, [replyTargetId, replyTargetMessage]);
React.useEffect(() => {
if (!activeChannel || activeChannel.channelType === "forum") {
return;
@@ -473,265 +477,273 @@ export function AppShell() {
}, [handleCloseSettings, handleOpenSettings, selectedView]);
return (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
{selectedView === "settings" ? (
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
isPresenceLoading={presenceSession.isLoading}
isUpdatingPresence={presenceSession.isPending}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetPresence={presenceSession.setStatus}
presenceError={presenceSession.error}
presenceStatus={presenceSession.currentStatus}
section={settingsSection}
/>
</div>
) : (
<React.Fragment>
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name, visibility }) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
});
<ChannelNavigationProvider
channels={channels}
onOpenChannel={handleOpenChannel}
>
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
{selectedView === "settings" ? (
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
isPresenceLoading={presenceSession.isLoading}
isUpdatingPresence={presenceSession.isPending}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetPresence={presenceSession.setStatus}
presenceError={presenceSession.error}
presenceStatus={presenceSession.currentStatus}
section={settingsSection}
/>
</div>
) : (
<React.Fragment>
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name, visibility }) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
});
openChannelView(createdChannel.id);
}}
onCreateForum={async ({ description, name, visibility }) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
});
openChannelView(createdChannel.id);
}}
onCreateForum={async ({ description, name, visibility }) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
});
openChannelView(createdForum.id);
}}
onOpenBrowseChannels={() => {
setBrowseDialogType("stream");
void refetchChannels();
}}
onOpenBrowseForums={() => {
setBrowseDialogType("forum");
void refetchChannels();
}}
onOpenSearch={() => {
setIsSearchOpen(true);
void refetchChannels();
}}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
openChannelView(directMessage.id);
}}
onSelectAgents={() => {
React.startTransition(() => {
setSelectedView("agents");
});
}}
onSelectHome={() => {
React.startTransition(() => {
setSelectedView("home");
});
openChannelView(createdForum.id);
}}
onOpenBrowseChannels={() => {
setBrowseDialogType("stream");
void refetchChannels();
}}
onOpenBrowseForums={() => {
setBrowseDialogType("forum");
void refetchChannels();
}}
onOpenSearch={() => {
setIsSearchOpen(true);
void refetchChannels();
}}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
openChannelView(directMessage.id);
}}
onSelectAgents={() => {
React.startTransition(() => {
setSelectedView("agents");
});
}}
onSelectHome={() => {
React.startTransition(() => {
setSelectedView("home");
});
void homeFeedQuery.refetch();
}}
onSelectChannel={handleOpenChannel}
onSelectSettings={handleOpenSettings}
profile={profileQuery.data}
selectedChannelId={selectedChannel?.id ?? null}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
void homeFeedQuery.refetch();
}}
onSelectChannel={handleOpenChannel}
onSelectSettings={handleOpenSettings}
profile={profileQuery.data}
selectedChannelId={selectedChannel?.id ?? null}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
) : selectedView === "agents" ? (
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>
) : (
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
/>
) : null
}
channelType={activeChannel?.channelType}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" &&
activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannelTitle}
/>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SidebarInset
className="min-h-0 min-w-0 overflow-hidden"
key={contentPaneKey}
>
{selectedView === "home" ? (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={handleOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
) : selectedView === "agents" ? (
<AgentsView />
) : activeChannel?.channelType === "forum" ? (
<ForumView
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelReply={() => {
setReplyTargetId(null);
}}
onReply={(message) => {
setReplyTargetId((current) =>
current === message.id ? null : message.id,
);
}}
onSend={async (content, mentionPubkeys, mediaTags) => {
await sendMessageMutation.mutateAsync({
content,
mentionPubkeys,
parentEventId: replyTargetId,
mediaTags,
});
setReplyTargetId(null);
}}
onTargetReached={(messageId) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,
);
}}
onToggleReaction={
activeChannel && activeChannel.archivedAt === null
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
}
: undefined
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
/>
) : null
}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel &&
searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
channelType={activeChannel?.channelType}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" &&
activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
typingPubkeys={typingPubkeys}
title={activeChannelTitle}
/>
)}
</div>
</SidebarInset>
</React.Fragment>
)}
<ChannelBrowserDialog
channels={channels}
channelTypeFilter={browseDialogType ?? "stream"}
onJoinChannel={handleBrowseChannelJoin}
onOpenChange={handleBrowseDialogOpenChange}
onSelectChannel={handleOpenChannel}
open={browseDialogType !== null}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{selectedView === "home" ? (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={handleOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
) : selectedView === "agents" ? (
<AgentsView />
) : activeChannel?.channelType === "forum" ? (
<ForumView
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
/>
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelReply={() => {
setReplyTargetId(null);
}}
onReply={(message) => {
setReplyTargetId((current) =>
current === message.id ? null : message.id,
);
}}
onSend={async (content, mentionPubkeys, mediaTags) => {
await sendMessageMutation.mutateAsync({
content,
mentionPubkeys,
parentEventId: replyTargetId,
mediaTags,
});
setReplyTargetId(null);
}}
onTargetReached={(messageId) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,
);
}}
onToggleReaction={
activeChannel && activeChannel.archivedAt === null
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
}
: undefined
}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel &&
searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
typingPubkeys={typingPubkeys}
/>
)}
</div>
</SidebarInset>
</React.Fragment>
)}
<SearchDialog
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
<ChannelBrowserDialog
channels={channels}
channelTypeFilter={browseDialogType ?? "stream"}
onJoinChannel={handleBrowseChannelJoin}
onOpenChange={handleBrowseDialogOpenChange}
onSelectChannel={handleOpenChannel}
open={browseDialogType !== null}
/>
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
</SidebarProvider>
<SearchDialog
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
</SidebarProvider>
</ChannelNavigationProvider>
);
}
@@ -1,7 +1,9 @@
import { Send } from "lucide-react";
import * as React from "react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import { useMentions } from "@/features/messages/lib/useMentions";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import { MentionAutocomplete } from "@/features/messages/ui/MentionAutocomplete";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
@@ -29,6 +31,7 @@ export function ForumComposer({
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
@@ -38,15 +41,34 @@ export function ForumComposer({
onSubmit(trimmed, pubkeys);
setValue("");
mentions.clearMentions();
channelLinks.clearChannels();
}
function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
const next = event.target.value;
setValue(next);
mentions.updateMentionQuery(next, event.target.selectionStart);
channelLinks.updateChannelQuery(next, event.target.selectionStart);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
const channelResult = channelLinks.handleChannelKeyDown(event);
if (channelResult.handled) {
if (channelResult.suggestion) {
const textarea = textareaRef.current;
const result = channelLinks.insertChannel(
channelResult.suggestion,
value,
textarea?.selectionEnd ?? value.length,
);
setValue(result.nextContent);
requestAnimationFrame(() => {
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
});
}
return;
}
const { handled, suggestion } = mentions.handleMentionKeyDown(event);
if (handled) {
if (suggestion) {
@@ -76,12 +98,32 @@ export function ForumComposer({
onSubmit(trimmed, pubkeys);
setValue("");
mentions.clearMentions();
channelLinks.clearChannels();
}
}
}
return (
<form className="relative flex flex-col gap-2" onSubmit={handleSubmit}>
<ChannelAutocomplete
onSelect={(suggestion) => {
const textarea = textareaRef.current;
const result = channelLinks.insertChannel(
suggestion,
value,
textarea?.selectionEnd ?? value.length,
);
setValue(result.nextContent);
requestAnimationFrame(() => {
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
textarea?.focus();
});
}}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
/>
<MentionAutocomplete
onSelect={(suggestion) => {
const textarea = textareaRef.current;
@@ -0,0 +1,153 @@
import * as React from "react";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
export type ChannelSuggestion = {
id: string;
name: string;
channelType: "stream" | "forum";
};
function detectChannelQuery(
value: string,
cursorPosition: number,
): { query: string; startIndex: number } | null {
const beforeCursor = value.slice(0, cursorPosition);
const match = beforeCursor.match(/(?:^|[\s])#([^\s]*)$/);
if (!match) {
return null;
}
const query = match[1];
const startIndex = beforeCursor.length - query.length - 1; // -1 for #
return { query, startIndex };
}
export function useChannelLinks() {
const { channels } = useChannelNavigation();
const [channelQuery, setChannelQuery] = React.useState<string | null>(null);
const [channelStartIndex, setChannelStartIndex] = React.useState(0);
const [channelSelectedIndex, setChannelSelectedIndex] = React.useState(0);
const channelSuggestions = React.useMemo<ChannelSuggestion[]>(() => {
if (channelQuery === null) {
return [];
}
const lowerQuery = channelQuery.toLowerCase();
return channels
.filter(
(ch) =>
ch.channelType !== "dm" && ch.name.toLowerCase().includes(lowerQuery),
)
.slice(0, 8)
.map((ch) => ({
id: ch.id,
name: ch.name,
channelType: ch.channelType as "stream" | "forum",
}));
}, [channels, channelQuery]);
const isChannelOpen = channelQuery !== null && channelSuggestions.length > 0;
const insertChannel = React.useCallback(
(
suggestion: ChannelSuggestion,
content: string,
selectionEnd: number,
): { nextContent: string; nextCursor: number } => {
const before = content.slice(0, channelStartIndex);
const after = content.slice(selectionEnd);
const inserted = `#${suggestion.name} `;
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
setChannelQuery(null);
setChannelSelectedIndex(0);
return { nextContent, nextCursor };
},
[channelStartIndex],
);
const updateChannelQuery = React.useCallback(
(value: string, cursorPosition: number) => {
const channel = detectChannelQuery(value, cursorPosition);
if (channel) {
setChannelQuery(channel.query);
setChannelStartIndex(channel.startIndex);
setChannelSelectedIndex(0);
} else {
setChannelQuery(null);
}
},
[],
);
const clearChannels = React.useCallback(() => {
setChannelQuery(null);
setChannelSelectedIndex(0);
}, []);
const handleChannelKeyDown = React.useCallback(
(
event: React.KeyboardEvent,
): { handled: boolean; suggestion?: ChannelSuggestion } => {
if (!isChannelOpen) {
return { handled: false };
}
if (event.key === "ArrowDown") {
event.preventDefault();
setChannelSelectedIndex((current) =>
current < channelSuggestions.length - 1 ? current + 1 : 0,
);
return { handled: true };
}
if (event.key === "ArrowUp") {
event.preventDefault();
setChannelSelectedIndex((current) =>
current > 0 ? current - 1 : channelSuggestions.length - 1,
);
return { handled: true };
}
if (
event.key === "Tab" ||
(event.key === "Enter" &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey)
) {
event.preventDefault();
return {
handled: true,
suggestion: channelSuggestions[channelSelectedIndex],
};
}
if (event.key === "Escape") {
event.preventDefault();
setChannelQuery(null);
return { handled: true };
}
return { handled: false };
},
[isChannelOpen, channelSelectedIndex, channelSuggestions],
);
return {
channelQuery,
channelSelectedIndex,
channelSuggestions,
clearChannels,
handleChannelKeyDown,
insertChannel,
isChannelOpen,
updateChannelQuery,
};
}
@@ -0,0 +1,61 @@
import * as React from "react";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { cn } from "@/shared/lib/cn";
type ChannelAutocompleteProps = {
suggestions: ChannelSuggestion[];
selectedIndex: number;
onSelect: (suggestion: ChannelSuggestion) => void;
};
export function ChannelAutocomplete({
suggestions,
selectedIndex,
onSelect,
}: ChannelAutocompleteProps) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const activeItem = listRef.current?.children[selectedIndex] as
| HTMLElement
| undefined;
activeItem?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (suggestions.length === 0) {
return null;
}
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 px-3 sm:px-4">
<div
className="max-h-48 overflow-y-auto rounded-xl border bg-popover p-1 shadow-lg"
ref={listRef}
>
{suggestions.map((suggestion, index) => (
<button
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/50",
)}
key={suggestion.id}
onMouseDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}
tabIndex={-1}
type="button"
>
<span className="truncate font-medium">#{suggestion.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{suggestion.channelType}
</span>
</button>
))}
</div>
</div>
);
}
@@ -1,7 +1,8 @@
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
type BlobDescriptor,
pickAndUploadMedia,
@@ -9,6 +10,7 @@ import {
} from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import {
MentionAutocomplete,
type MentionSuggestion,
@@ -67,10 +69,8 @@ export function MessageComposer({
const draftSelectionRef = React.useRef({ end: 0, start: 0 });
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
const [mentionStartIndex, setMentionStartIndex] = React.useState(0);
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
const [uploadState, setUploadState] = React.useState<{
status: "idle" | "uploading" | "error";
@@ -78,127 +78,51 @@ export function MessageComposer({
}>({ status: "idle" });
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — reset all composer state on channel switch to prevent draft/upload/mention leaks
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — reset all composer state on channel switch to prevent draft/upload/autocomplete leaks
React.useEffect(() => {
setContent("");
setPendingImeta([]);
setUploadState({ status: "idle" });
setMentionQuery(null);
setMentionStartIndex(0);
setMentionSelectedIndex(0);
setIsEmojiPickerOpen(false);
mentionMapRef.current.clear();
mentions.clearMentions();
channelLinks.clearChannels();
draftSelectionRef.current = { end: 0, start: 0 };
pendingSelectionRef.current = null;
}, [channelId]);
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data ?? [];
const managedAgentsQuery = useManagedAgentsQuery();
const managedAgentNamesByPubkey = React.useMemo(
() =>
new Map(
(managedAgentsQuery.data ?? []).map((agent) => [
agent.pubkey.toLowerCase(),
agent.name,
]),
),
[managedAgentsQuery.data],
);
const suggestions = React.useMemo<MentionSuggestion[]>(() => {
if (mentionQuery === null) {
return [];
}
const lowerQuery = mentionQuery.toLowerCase();
return members
.map((member) => {
const fallbackName =
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
member.pubkey.slice(0, 8);
return {
member,
label: member.displayName ?? fallbackName,
};
})
.filter(
({ label, member }) =>
label.toLowerCase().includes(lowerQuery) ||
member.pubkey.toLowerCase().includes(lowerQuery),
)
.slice(0, 8)
.map(({ member, label }) => ({
pubkey: member.pubkey,
displayName: label,
role: member.role === "admin" ? "admin" : null,
}));
}, [managedAgentNamesByPubkey, members, mentionQuery]);
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
const insertMention = React.useCallback(
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const displayName = suggestion.displayName;
const before = content.slice(0, mentionStartIndex);
const after = content.slice(textarea.selectionEnd);
const inserted = `@${displayName} `;
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
mentionMapRef.current.set(displayName, suggestion.pubkey);
const result = mentions.insertMention(
suggestion,
content,
textarea?.selectionEnd ?? content.length,
);
draftSelectionRef.current = {
end: nextCursor,
start: nextCursor,
end: result.nextCursor,
start: result.nextCursor,
};
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
setMentionQuery(null);
setMentionSelectedIndex(0);
pendingSelectionRef.current = result.nextCursor;
setContent(result.nextContent);
},
[content, mentionStartIndex],
[content, mentions.insertMention],
);
const extractMentionPubkeys = React.useCallback(
(text: string): string[] => {
const pubkeys: string[] = [];
const hasMention = (name: string): boolean => {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(?:^|\\s)@${escaped}(?=[\\s,;.!?:)\\]}]|$)`,
"i",
);
return pattern.test(text);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const textarea = textareaRef.current;
const result = channelLinks.insertChannel(
suggestion,
content,
textarea?.selectionEnd ?? content.length,
);
draftSelectionRef.current = {
end: result.nextCursor,
start: result.nextCursor,
};
for (const [displayName, pubkey] of mentionMapRef.current) {
if (hasMention(displayName)) {
pubkeys.push(pubkey);
}
}
for (const member of members) {
if (pubkeys.includes(member.pubkey)) {
continue;
}
const name =
member.displayName ??
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
if (name && hasMention(name)) {
pubkeys.push(member.pubkey);
}
}
return [...new Set(pubkeys)];
pendingSelectionRef.current = result.nextCursor;
setContent(result.nextContent);
},
[members, managedAgentNamesByPubkey],
[content, channelLinks.insertChannel],
);
const updateDraftSelection = React.useCallback(
@@ -230,10 +154,9 @@ export function MessageComposer({
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
setIsEmojiPickerOpen(false);
setMentionQuery(null);
setMentionSelectedIndex(0);
mentions.clearMentions();
},
[content],
[content, mentions.clearMentions],
);
const openMentionPicker = React.useCallback(() => {
@@ -245,9 +168,7 @@ export function MessageComposer({
const cursorPosition = textarea.selectionStart ?? content.length;
const existingMention = detectMentionQuery(content, cursorPosition);
if (existingMention) {
setMentionStartIndex(existingMention.startIndex);
setMentionQuery(existingMention.query);
setMentionSelectedIndex(0);
mentions.updateMentionQuery(content, cursorPosition);
textarea.focus();
return;
}
@@ -271,10 +192,8 @@ export function MessageComposer({
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
setIsEmojiPickerOpen(false);
setMentionStartIndex(mentionIndex);
setMentionQuery("");
setMentionSelectedIndex(0);
}, [content]);
mentions.updateMentionQuery(nextContent, nextCursor);
}, [content, mentions.updateMentionQuery]);
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
@@ -374,7 +293,7 @@ export function MessageComposer({
return;
}
const pubkeys = extractMentionPubkeys(trimmed);
const pubkeys = mentions.extractMentionPubkeys(trimmed);
const mediaTags =
pendingImeta.length > 0
@@ -396,8 +315,8 @@ export function MessageComposer({
setContent("");
draftSelectionRef.current = { end: 0, start: 0 };
setPendingImeta([]);
mentionMapRef.current.clear();
setMentionQuery(null);
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
try {
@@ -411,7 +330,9 @@ export function MessageComposer({
disabled,
isSending,
onSend,
extractMentionPubkeys,
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
pendingImeta,
]);
@@ -429,56 +350,32 @@ export function MessageComposer({
const cursorPos = event.target.selectionStart;
setContent(nextContent);
updateDraftSelection(event.target);
const mention = detectMentionQuery(nextContent, cursorPos);
if (mention) {
setMentionQuery(mention.query);
setMentionStartIndex(mention.startIndex);
setMentionSelectedIndex(0);
} else {
setMentionQuery(null);
}
mentions.updateMentionQuery(nextContent, cursorPos);
channelLinks.updateChannelQuery(nextContent, cursorPos);
},
[updateDraftSelection],
[
updateDraftSelection,
mentions.updateMentionQuery,
channelLinks.updateChannelQuery,
],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isMentionOpen) {
if (event.key === "ArrowDown") {
event.preventDefault();
setMentionSelectedIndex((current) =>
current < suggestions.length - 1 ? current + 1 : 0,
);
return;
const channelResult = channelLinks.handleChannelKeyDown(event);
if (channelResult.handled) {
if (channelResult.suggestion) {
applyChannelInsert(channelResult.suggestion);
}
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMentionSelectedIndex((current) =>
current > 0 ? current - 1 : suggestions.length - 1,
);
return;
}
if (
event.key === "Tab" ||
(event.key === "Enter" &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey)
) {
event.preventDefault();
insertMention(suggestions[mentionSelectedIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMentionQuery(null);
return;
const { handled, suggestion } = mentions.handleMentionKeyDown(event);
if (handled) {
if (suggestion) {
applyMentionInsert(suggestion);
}
return;
}
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
@@ -508,10 +405,10 @@ export function MessageComposer({
void submitMessage();
},
[
isMentionOpen,
suggestions,
mentionSelectedIndex,
insertMention,
channelLinks.handleChannelKeyDown,
applyChannelInsert,
mentions.handleMentionKeyDown,
applyMentionInsert,
submitMessage,
],
);
@@ -568,10 +465,17 @@ export function MessageComposer({
handleSubmit(event);
}}
>
<ChannelAutocomplete
onSelect={applyChannelInsert}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
/>
<MentionAutocomplete
onSelect={insertMention}
selectedIndex={mentionSelectedIndex}
suggestions={isMentionOpen ? suggestions : []}
onSelect={applyMentionInsert}
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
{replyTarget ? (
@@ -0,0 +1,39 @@
import * as React from "react";
import type { Channel } from "@/shared/api/types";
type ChannelNavigationContextValue = {
channels: Channel[];
onOpenChannel: (channelId: string) => void;
};
const ChannelNavigationContext =
React.createContext<ChannelNavigationContextValue>({
channels: [],
onOpenChannel: () => {},
});
export function ChannelNavigationProvider({
channels,
children,
onOpenChannel,
}: {
channels: Channel[];
children: React.ReactNode;
onOpenChannel: (channelId: string) => void;
}) {
const value = React.useMemo(
() => ({ channels, onOpenChannel }),
[channels, onOpenChannel],
);
return (
<ChannelNavigationContext.Provider value={value}>
{children}
</ChannelNavigationContext.Provider>
);
}
export function useChannelNavigation() {
return React.useContext(ChannelNavigationContext);
}
@@ -0,0 +1,73 @@
/**
* Remark plugin that detects #channel-name patterns in text nodes and wraps them
* in custom HAST `channel-link` elements for styled rendering via react-markdown.
*/
export default function remarkChannelLinks() {
// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available
return (tree: any) => {
walkChildren(tree);
};
}
// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available
function walkChildren(node: any) {
if (!node?.children || !Array.isArray(node.children)) {
return;
}
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
if (child.type === "text") {
const parts = splitChannelLinks(child.value);
if (parts.length > 1) {
node.children.splice(i, 1, ...parts);
}
} else {
walkChildren(child);
}
}
}
function splitChannelLinks(text: string) {
const channelPattern = /#[a-zA-Z0-9][\w-]*/g;
// biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes
const parts: any[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null = null;
while (true) {
match = channelPattern.exec(text);
if (!match) {
break;
}
if (match.index > lastIndex) {
parts.push({ type: "text", value: text.slice(lastIndex, match.index) });
}
const channelName = match[0].slice(1);
parts.push({
type: "channel-link",
value: match[0],
data: {
hName: "channel-link",
hChildren: [{ type: "text", value: match[0] }],
channelName,
},
});
lastIndex = match.index + match[0].length;
}
if (parts.length === 0) {
return [{ type: "text", value: text }];
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
+46 -3
View File
@@ -4,7 +4,10 @@ import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { cn } from "@/shared/lib/cn";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkMentions from "@/shared/lib/remarkMentions";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import type { Channel } from "@/shared/api/types";
type MarkdownProps = {
className?: string;
@@ -15,7 +18,11 @@ type MarkdownProps = {
type MarkdownVariant = "default" | "compact" | "tight";
function createMarkdownComponents(variant: MarkdownVariant): Components {
function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
onOpenChannel: (channelId: string) => void,
): Components {
const paragraphClassName =
variant === "tight"
? "leading-5"
@@ -139,6 +146,36 @@ function createMarkdownComponents(variant: MarkdownVariant): Components {
{children}
</span>
),
"channel-link": ({ children }: { children?: React.ReactNode }) => {
const text = String(children ?? "");
const channelName = text.startsWith("#") ? text.slice(1) : text;
const channel = channels.find(
(c) =>
c.channelType !== "dm" &&
c.name.toLowerCase() === channelName.toLowerCase(),
);
if (channel) {
return (
<button
type="button"
aria-label={`Open channel ${channelName}`}
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors"
onClick={() => {
onOpenChannel(channel.id);
}}
>
{children}
</button>
);
}
return (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary">
{children}
</span>
);
},
} as Components;
}
@@ -149,6 +186,7 @@ export function Markdown({
tight = false,
}: MarkdownProps) {
const variant = tight ? "tight" : compact ? "compact" : "default";
const { channels, onOpenChannel } = useChannelNavigation();
let processedContent = content;
if (/^(?:\s{2}\n)+/.test(content)) {
@@ -171,8 +209,13 @@ export function Markdown({
)}
>
<ReactMarkdown
components={createMarkdownComponents(variant)}
remarkPlugins={[remarkGfm, remarkBreaks, remarkMentions]}
components={createMarkdownComponents(variant, channels, onOpenChannel)}
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkMentions,
remarkChannelLinks,
]}
>
{processedContent}
</ReactMarkdown>