mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add #channel link rendering and autocomplete (#135)
This commit is contained in:
Generated
+299
-318
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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\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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user