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

617
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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"

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>
);
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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 ? (

View File

@@ -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);
}

View File

@@ -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;
}

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>