mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
Refactor chat components to separate generic UI from Nostr-specific implementation
Creates reusable chat components in src/components/chat/shared/ that are protocol-agnostic and can be used for any chat implementation (Matrix, XMPP, etc.). New generic components: - ChatWindow: Complete chat interface layout with loading/error states - MessageList: Virtualized message list with day markers and infinite scroll - MessageComposer: Message input with mentions, emojis, commands, attachments - ChatHeader: Flexible header layout with prefix/suffix areas - DayMarker: Date separator component - date-utils: Utilities for formatting dates and inserting day markers - types: Generic TypeScript interfaces for chat components The existing ChatViewer now uses these generic components via render props, providing Nostr-specific rendering and state management while maintaining all existing functionality. Benefits: - Clean separation of UI and protocol logic - Reusable components for other chat protocols - Maintained type safety with TypeScript generics - No breaking changes to existing chat functionality
This commit is contained in:
@@ -1,17 +1,7 @@
|
||||
import { useMemo, useState, memo, useCallback, useRef, useEffect } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { from, catchError, of, map } from "rxjs";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import {
|
||||
Loader2,
|
||||
Reply,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Paperclip,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
} from "lucide-react";
|
||||
import { Reply, Zap, Copy, CopyCheck } from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getZapRequest } from "applesauce-common/helpers/zap";
|
||||
import { toast } from "sonner";
|
||||
@@ -40,17 +30,14 @@ import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { StatusBadge } from "./live/StatusBadge";
|
||||
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
MentionEditor,
|
||||
type MentionEditorHandle,
|
||||
type EmojiTag,
|
||||
type BlobAttachment,
|
||||
import type {
|
||||
MentionEditorHandle,
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
} from "./editor/MentionEditor";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -58,6 +45,11 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
import {
|
||||
ChatWindow,
|
||||
insertDayMarkers,
|
||||
type ChatLoadingState,
|
||||
} from "./chat/shared";
|
||||
|
||||
interface ChatViewerProps {
|
||||
protocol: ChatProtocol;
|
||||
@@ -67,59 +59,6 @@ interface ChatViewerProps {
|
||||
headerPrefix?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format timestamp as a readable day marker
|
||||
*/
|
||||
function formatDayMarker(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Reset time parts for comparison
|
||||
const dateOnly = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
);
|
||||
const todayOnly = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
const yesterdayOnly = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
);
|
||||
|
||||
if (dateOnly.getTime() === todayOnly.getTime()) {
|
||||
return "Today";
|
||||
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
// Format as "Jan 15" (short month, no year, respects locale)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if two timestamps are on different days
|
||||
*/
|
||||
function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
const date1 = new Date(timestamp1 * 1000);
|
||||
const date2 = new Date(timestamp2 * 1000);
|
||||
|
||||
return (
|
||||
date1.getFullYear() !== date2.getFullYear() ||
|
||||
date1.getMonth() !== date2.getMonth() ||
|
||||
date1.getDate() !== date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for LiveActivityMetadata
|
||||
*/
|
||||
@@ -470,12 +409,23 @@ export function ChatViewer({
|
||||
[adapter, identifier, retryCount],
|
||||
);
|
||||
|
||||
// Extract conversation from result (null while loading or on error)
|
||||
// Extract conversation and loading state from result
|
||||
const conversation =
|
||||
conversationResult?.status === "success"
|
||||
? conversationResult.conversation
|
||||
: null;
|
||||
|
||||
const loadingState: ChatLoadingState = !conversationResult
|
||||
? "loading"
|
||||
: conversationResult.status === "error"
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
const errorMessage =
|
||||
conversationResult?.status === "error"
|
||||
? conversationResult.error
|
||||
: undefined;
|
||||
|
||||
// Slash command search for action autocomplete
|
||||
// Context-aware: only shows relevant actions based on membership status
|
||||
const searchCommands = useCallback(
|
||||
@@ -507,40 +457,11 @@ export function ChatViewer({
|
||||
[adapter, conversation],
|
||||
);
|
||||
|
||||
// Process messages to include day markers
|
||||
const messagesWithMarkers = useMemo(() => {
|
||||
if (!messages || messages.length === 0) return [];
|
||||
|
||||
const items: Array<
|
||||
| { type: "message"; data: Message }
|
||||
| { type: "day-marker"; data: string; timestamp: number }
|
||||
> = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
// Add day marker if this is the first message or if day changed
|
||||
if (index === 0) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
} else {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (isDifferentDay(prevMessage.timestamp, message.timestamp)) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the message itself
|
||||
items.push({ type: "message", data: message });
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [messages]);
|
||||
// Process messages to include day markers (using generic utility)
|
||||
const messagesWithMarkers = useMemo(
|
||||
() => insertDayMarkers(messages || []),
|
||||
[messages],
|
||||
);
|
||||
|
||||
// Track reply context (which message is being replied to)
|
||||
const [replyTo, setReplyTo] = useState<string | undefined>();
|
||||
@@ -549,9 +470,6 @@ export function ChatViewer({
|
||||
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// Ref to Virtuoso for programmatic scrolling
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// State for send in progress (prevents double-sends)
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
@@ -652,26 +570,6 @@ export function ChatViewer({
|
||||
editorRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Handle scroll to message (when clicking on reply preview)
|
||||
// Must search in messagesWithMarkers since that's what Virtuoso renders
|
||||
const handleScrollToMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
if (!messagesWithMarkers) return;
|
||||
// Find index in the rendered array (which includes day markers)
|
||||
const index = messagesWithMarkers.findIndex(
|
||||
(item) => item.type === "message" && item.data.id === messageId,
|
||||
);
|
||||
if (index !== -1 && virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index,
|
||||
align: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
[messagesWithMarkers],
|
||||
);
|
||||
|
||||
// Handle loading older messages
|
||||
const handleLoadOlder = useCallback(async () => {
|
||||
if (!conversation || !messages || messages.length === 0 || isLoadingOlder) {
|
||||
@@ -745,295 +643,184 @@ export function ChatViewer({
|
||||
liveActivity?.hostPubkey,
|
||||
]);
|
||||
|
||||
// Handle loading state
|
||||
if (!conversationResult || conversationResult.status === "loading") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<span className="text-xs">Loading conversation...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Render function for messages (Nostr-specific)
|
||||
const renderMessage = useCallback(
|
||||
(message: Message, onScrollToMessage?: (messageId: string) => void) => (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
adapter={adapter}
|
||||
conversation={conversation!}
|
||||
onReply={handleReply}
|
||||
canReply={hasActiveAccount}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
),
|
||||
[adapter, conversation, handleReply, hasActiveAccount],
|
||||
);
|
||||
|
||||
// Handle error state with retry option
|
||||
if (conversationResult.status === "error") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground p-4">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<span className="text-center text-sm">{conversationResult.error}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
className="gap-2"
|
||||
// Header content (Nostr-specific)
|
||||
const headerContent = conversation && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-sm font-semibold truncate cursor-help text-left"
|
||||
onClick={() => setTooltipOpen(!tooltipOpen)}
|
||||
>
|
||||
{customTitle || conversation.title}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-md p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Icon + Name */}
|
||||
<div className="flex items-center gap-2">
|
||||
{conversation.metadata?.icon && (
|
||||
<img
|
||||
src={conversation.metadata.icon}
|
||||
alt=""
|
||||
className="size-6 rounded object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold">{conversation.title}</span>
|
||||
</div>
|
||||
{/* Description */}
|
||||
{conversation.metadata?.description && (
|
||||
<p className="text-xs text-primary-foreground/90">
|
||||
{conversation.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Protocol Type - Clickable */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNipClick();
|
||||
}}
|
||||
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<span className="text-primary-foreground/60">•</span>
|
||||
)}
|
||||
<span className="capitalize text-primary-foreground/80">
|
||||
{conversation.type}
|
||||
</span>
|
||||
</div>
|
||||
{/* Live Activity Status */}
|
||||
{liveActivity?.status && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-primary-foreground/80">Status:</span>
|
||||
<StatusBadge status={liveActivity.status} size="xs" />
|
||||
</div>
|
||||
)}
|
||||
{/* Host Info */}
|
||||
{liveActivity?.hostPubkey && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<span>Host:</span>
|
||||
<UserName
|
||||
pubkey={liveActivity.hostPubkey}
|
||||
className="text-xs text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Copy Chat ID button */}
|
||||
{getChatIdentifier(conversation) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const chatId = getChatIdentifier(conversation);
|
||||
if (chatId) copyChatId(chatId);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Copy chat ID"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{chatIdCopied ? (
|
||||
<CopyCheck className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// At this point conversation is guaranteed to exist
|
||||
if (!conversation) {
|
||||
return null; // Should never happen, but satisfies TypeScript
|
||||
}
|
||||
// Header suffix (Nostr-specific controls)
|
||||
const headerSuffix = conversation && (
|
||||
<>
|
||||
<MembersDropdown participants={derivedParticipants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{(conversation.type === "group" || conversation.type === "live-chat") && (
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Reply preview for composer
|
||||
const replyPreview = replyTo ? (
|
||||
<ComposerReplyPreview
|
||||
replyToId={replyTo}
|
||||
onClear={() => setReplyTo(undefined)}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with conversation info and controls */}
|
||||
<div className="pl-2 pr-0 border-b w-full py-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
{headerPrefix}
|
||||
<TooltipProvider>
|
||||
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-sm font-semibold truncate cursor-help text-left"
|
||||
onClick={() => setTooltipOpen(!tooltipOpen)}
|
||||
>
|
||||
{customTitle || conversation.title}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-md p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Icon + Name */}
|
||||
<div className="flex items-center gap-2">
|
||||
{conversation.metadata?.icon && (
|
||||
<img
|
||||
src={conversation.metadata.icon}
|
||||
alt=""
|
||||
className="size-6 rounded object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{conversation.title}
|
||||
</span>
|
||||
</div>
|
||||
{/* Description */}
|
||||
{conversation.metadata?.description && (
|
||||
<p className="text-xs text-primary-foreground/90">
|
||||
{conversation.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Protocol Type - Clickable */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNipClick();
|
||||
}}
|
||||
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<span className="text-primary-foreground/60">•</span>
|
||||
)}
|
||||
<span className="capitalize text-primary-foreground/80">
|
||||
{conversation.type}
|
||||
</span>
|
||||
</div>
|
||||
{/* Live Activity Status */}
|
||||
{liveActivity?.status && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-primary-foreground/80">
|
||||
Status:
|
||||
</span>
|
||||
<StatusBadge status={liveActivity.status} size="xs" />
|
||||
</div>
|
||||
)}
|
||||
{/* Host Info */}
|
||||
{liveActivity?.hostPubkey && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<span>Host:</span>
|
||||
<UserName
|
||||
pubkey={liveActivity.hostPubkey}
|
||||
className="text-xs text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Copy Chat ID button */}
|
||||
{getChatIdentifier(conversation) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const chatId = getChatIdentifier(conversation);
|
||||
if (chatId) copyChatId(chatId);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Copy chat ID"
|
||||
>
|
||||
{chatIdCopied ? (
|
||||
<CopyCheck className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
<MembersDropdown participants={derivedParticipants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{(conversation.type === "group" ||
|
||||
conversation.type === "live-chat") && (
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message timeline with virtualization */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messagesWithMarkers}
|
||||
initialTopMostItemIndex={messagesWithMarkers.length - 1}
|
||||
followOutput="smooth"
|
||||
alignToBottom
|
||||
components={{
|
||||
Header: () =>
|
||||
hasMore && conversationResult.status === "success" ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
onClick={handleLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{isLoadingOlder ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span className="text-xs">Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
"Load older messages"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null,
|
||||
Footer: () => <div className="h-1" />,
|
||||
}}
|
||||
itemContent={(_index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center py-2"
|
||||
key={`marker-${item.timestamp}`}
|
||||
>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{item.data}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageItem
|
||||
key={item.data.id}
|
||||
message={item.data}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onReply={handleReply}
|
||||
canReply={hasActiveAccount}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No messages yet. Start the conversation!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message composer - only show if user has active account */}
|
||||
{hasActiveAccount ? (
|
||||
<div className="border-t px-2 py-1 pb-0">
|
||||
{replyTo && (
|
||||
<ComposerReplyPreview
|
||||
replyToId={replyTo}
|
||||
onClear={() => setReplyTo(undefined)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={openUpload}
|
||||
disabled={isSending}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Attach media</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
searchProfiles={searchProfiles}
|
||||
searchEmojis={searchEmojis}
|
||||
searchCommands={searchCommands}
|
||||
onCommandExecute={handleCommandExecute}
|
||||
onSubmit={(content, emojiTags, blobAttachments) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo, emojiTags, blobAttachments);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-shrink-0 h-7 px-2 text-xs"
|
||||
disabled={isSending}
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadDialog}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
Sign in to send messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatWindow
|
||||
loadingState={loadingState}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={() => setRetryCount((c) => c + 1)}
|
||||
header={headerContent}
|
||||
headerPrefix={headerPrefix}
|
||||
headerSuffix={headerSuffix}
|
||||
messages={messagesWithMarkers}
|
||||
renderMessage={renderMessage}
|
||||
emptyState="No messages yet. Start the conversation!"
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingOlder}
|
||||
onLoadMore={handleLoadOlder}
|
||||
composer={{
|
||||
placeholder: "Type a message...",
|
||||
isSending,
|
||||
disabled: !hasActiveAccount,
|
||||
disabledMessage: "Sign in to send messages",
|
||||
replyPreview,
|
||||
onSearchProfiles: searchProfiles as (
|
||||
query: string,
|
||||
) => Promise<unknown[]>,
|
||||
onSearchEmojis: searchEmojis as (query: string) => Promise<unknown[]>,
|
||||
onSearchCommands: searchCommands as (
|
||||
query: string,
|
||||
) => Promise<unknown[]>,
|
||||
onCommandExecute: handleCommandExecute as (
|
||||
command: unknown,
|
||||
) => Promise<void>,
|
||||
onSubmit: (content, emojiTags, blobAttachments) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo, emojiTags, blobAttachments);
|
||||
}
|
||||
},
|
||||
onAttach: openUpload,
|
||||
attachDialog: uploadDialog,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
32
src/components/chat/shared/ChatHeader.tsx
Normal file
32
src/components/chat/shared/ChatHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
/** Optional prefix content (e.g., sidebar toggle) */
|
||||
prefix?: ReactNode;
|
||||
/** Main header content (title, buttons, etc.) */
|
||||
children: ReactNode;
|
||||
/** Optional suffix content (e.g., action buttons) */
|
||||
suffix?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatHeader - Generic header layout for chat windows
|
||||
* Provides a flexible layout with prefix, main content, and suffix areas
|
||||
*/
|
||||
export function ChatHeader({ prefix, children, suffix }: ChatHeaderProps) {
|
||||
return (
|
||||
<div className="pl-2 pr-0 border-b w-full py-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
{prefix}
|
||||
{children}
|
||||
</div>
|
||||
{suffix && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/components/chat/shared/ChatWindow.tsx
Normal file
143
src/components/chat/shared/ChatWindow.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Loader2, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import type { MessageListItem, ChatLoadingState } from "./types";
|
||||
import type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
} from "@/components/editor/MentionEditor";
|
||||
|
||||
interface ChatWindowProps<T extends { id: string; timestamp: number }> {
|
||||
/** Loading state for the chat */
|
||||
loadingState: ChatLoadingState;
|
||||
/** Error message (when loadingState is "error") */
|
||||
errorMessage?: string;
|
||||
/** Called when user clicks retry after error */
|
||||
onRetry?: () => void;
|
||||
|
||||
/** Header content */
|
||||
header: ReactNode;
|
||||
/** Header prefix (e.g., sidebar toggle) */
|
||||
headerPrefix?: ReactNode;
|
||||
/** Header suffix (e.g., member count, relay status) */
|
||||
headerSuffix?: ReactNode;
|
||||
|
||||
/** Messages to display (with day markers) */
|
||||
messages: MessageListItem<T>[];
|
||||
/** Render function for messages */
|
||||
renderMessage: (
|
||||
message: T,
|
||||
onScrollToMessage?: (messageId: string) => void,
|
||||
) => ReactNode;
|
||||
/** Empty state content */
|
||||
emptyState?: ReactNode;
|
||||
|
||||
/** Whether there are more messages to load */
|
||||
hasMore?: boolean;
|
||||
/** Whether loading older messages is in progress */
|
||||
isLoadingMore?: boolean;
|
||||
/** Called when user requests to load older messages */
|
||||
onLoadMore?: () => void;
|
||||
|
||||
/** Message composer props */
|
||||
composer: {
|
||||
placeholder?: string;
|
||||
isSending: boolean;
|
||||
disabled?: boolean;
|
||||
disabledMessage?: string;
|
||||
replyPreview?: ReactNode;
|
||||
onSearchProfiles?: (query: string) => Promise<unknown[]>;
|
||||
onSearchEmojis?: (query: string) => Promise<unknown[]>;
|
||||
onSearchCommands?: (query: string) => Promise<unknown[]>;
|
||||
onCommandExecute?: (command: unknown) => void | Promise<void>;
|
||||
onSubmit: (
|
||||
content: string,
|
||||
emojiTags?: EmojiTag[],
|
||||
blobAttachments?: BlobAttachment[],
|
||||
) => void;
|
||||
onAttach?: () => void;
|
||||
attachDialog?: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatWindow - Generic chat window layout
|
||||
* Provides a complete chat interface with header, message list, and composer
|
||||
* Protocol-agnostic - works with any chat system
|
||||
*/
|
||||
export function ChatWindow<T extends { id: string; timestamp: number }>({
|
||||
loadingState,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
header,
|
||||
headerPrefix,
|
||||
headerSuffix,
|
||||
messages,
|
||||
renderMessage,
|
||||
emptyState,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
composer,
|
||||
}: ChatWindowProps<T>) {
|
||||
// Loading state
|
||||
if (loadingState === "loading") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<span className="text-xs">Loading conversation...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state with retry
|
||||
if (loadingState === "error") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground p-4">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<span className="text-center text-sm">
|
||||
{errorMessage || "Failed to load conversation"}
|
||||
</span>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state - show chat interface
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<ChatHeader prefix={headerPrefix} suffix={headerSuffix}>
|
||||
{header}
|
||||
</ChatHeader>
|
||||
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MessageList
|
||||
items={messages}
|
||||
renderMessage={renderMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
emptyState={emptyState}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message composer */}
|
||||
<MessageComposer {...composer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/chat/shared/DayMarker.tsx
Normal file
18
src/components/chat/shared/DayMarker.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface DayMarkerProps {
|
||||
label: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DayMarker - Generic day separator for chat messages
|
||||
* Displays a date label between messages from different days
|
||||
*/
|
||||
export function DayMarker({ label, timestamp }: DayMarkerProps) {
|
||||
return (
|
||||
<div className="flex justify-center py-2" key={`marker-${timestamp}`}>
|
||||
<Label className="text-[10px] text-muted-foreground">{label}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/chat/shared/MessageComposer.tsx
Normal file
148
src/components/chat/shared/MessageComposer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useRef } from "react";
|
||||
import { Loader2, Paperclip } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
MentionEditor,
|
||||
type MentionEditorHandle,
|
||||
type EmojiTag,
|
||||
type BlobAttachment,
|
||||
} from "@/components/editor/MentionEditor";
|
||||
|
||||
interface MessageComposerProps {
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string;
|
||||
/** Whether sending is in progress */
|
||||
isSending: boolean;
|
||||
/** Whether the user can send messages */
|
||||
disabled?: boolean;
|
||||
/** Message to disable the composer (shown when disabled=true) */
|
||||
disabledMessage?: string;
|
||||
/** Optional reply preview to show above the input */
|
||||
replyPreview?: React.ReactNode;
|
||||
/** Search function for @ mentions */
|
||||
onSearchProfiles?: (query: string) => Promise<unknown[]>;
|
||||
/** Search function for : emoji autocomplete */
|
||||
onSearchEmojis?: (query: string) => Promise<unknown[]>;
|
||||
/** Search function for / command autocomplete */
|
||||
onSearchCommands?: (query: string) => Promise<unknown[]>;
|
||||
/** Called when a command is executed from autocomplete */
|
||||
onCommandExecute?: (command: unknown) => void | Promise<void>;
|
||||
/** Called when user submits a message */
|
||||
onSubmit: (
|
||||
content: string,
|
||||
emojiTags?: EmojiTag[],
|
||||
blobAttachments?: BlobAttachment[],
|
||||
) => void;
|
||||
/** Optional attach button handler */
|
||||
onAttach?: () => void;
|
||||
/** Optional dialog for attachments (e.g., Blossom upload) */
|
||||
attachDialog?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageComposer - Generic message input component
|
||||
* Handles text input, reply preview, mentions, emojis, commands, and attachments
|
||||
*/
|
||||
export function MessageComposer({
|
||||
placeholder = "Type a message...",
|
||||
isSending,
|
||||
disabled = false,
|
||||
disabledMessage = "Sign in to send messages",
|
||||
replyPreview,
|
||||
onSearchProfiles,
|
||||
onSearchEmojis,
|
||||
onSearchCommands,
|
||||
onCommandExecute,
|
||||
onSubmit,
|
||||
onAttach,
|
||||
attachDialog,
|
||||
}: MessageComposerProps) {
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// Handle submission from editor or button
|
||||
const handleSubmit = (
|
||||
content: string,
|
||||
emojiTags?: EmojiTag[],
|
||||
blobAttachments?: BlobAttachment[],
|
||||
) => {
|
||||
if (content.trim() && !disabled) {
|
||||
onSubmit(content, emojiTags, blobAttachments);
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
{disabledMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t px-2 py-1 pb-0">
|
||||
{replyPreview}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{onAttach && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onAttach}
|
||||
disabled={isSending}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Attach media</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
searchProfiles={onSearchProfiles as any}
|
||||
searchEmojis={onSearchEmojis as any}
|
||||
searchCommands={onSearchCommands as any}
|
||||
onCommandExecute={onCommandExecute as any}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-shrink-0 h-7 px-2 text-xs"
|
||||
disabled={isSending}
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
{attachDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to expose editor ref to parent components
|
||||
* Useful for programmatic control (focus, insert, etc.)
|
||||
*/
|
||||
export function useMessageComposerRef() {
|
||||
return useRef<MentionEditorHandle>(null);
|
||||
}
|
||||
|
||||
export type { MentionEditorHandle as MessageComposerHandle };
|
||||
117
src/components/chat/shared/MessageList.tsx
Normal file
117
src/components/chat/shared/MessageList.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useRef, useCallback, ReactNode } from "react";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DayMarker } from "./DayMarker";
|
||||
import type { MessageListItem } from "./types";
|
||||
|
||||
interface MessageListProps<T extends { id: string; timestamp: number }> {
|
||||
/** Items to render (messages + day markers) */
|
||||
items: MessageListItem<T>[];
|
||||
/** Render function for messages */
|
||||
renderMessage: (
|
||||
message: T,
|
||||
onScrollToMessage?: (messageId: string) => void,
|
||||
) => ReactNode;
|
||||
/** Whether there are more messages to load */
|
||||
hasMore?: boolean;
|
||||
/** Whether loading is in progress */
|
||||
isLoading?: boolean;
|
||||
/** Called when user requests to load older messages */
|
||||
onLoadMore?: () => void;
|
||||
/** Empty state content */
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageList - Generic virtualized message list with day markers
|
||||
* Handles infinite scrolling, day separators, and message rendering
|
||||
*/
|
||||
export function MessageList<T extends { id: string; timestamp: number }>({
|
||||
items,
|
||||
renderMessage,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
onLoadMore,
|
||||
emptyState,
|
||||
}: MessageListProps<T>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Handle scroll to message
|
||||
const handleScrollToMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
if (!items) return;
|
||||
// Find index in the rendered array (which includes day markers)
|
||||
const index = items.findIndex(
|
||||
(item) => item.type === "message" && item.data.id === messageId,
|
||||
);
|
||||
if (index !== -1 && virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index,
|
||||
align: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
// Show empty state if no items
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{emptyState || "No messages yet. Start the conversation!"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={items}
|
||||
initialTopMostItemIndex={items.length - 1}
|
||||
followOutput="smooth"
|
||||
alignToBottom
|
||||
components={{
|
||||
Header: () =>
|
||||
hasMore && onLoadMore ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span className="text-xs">Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
"Load older messages"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null,
|
||||
Footer: () => <div className="h-1" />,
|
||||
}}
|
||||
itemContent={(_index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
return <DayMarker label={item.data} timestamp={item.timestamp} />;
|
||||
}
|
||||
return renderMessage(item.data, handleScrollToMessage);
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to expose virtuoso ref to parent components
|
||||
* Useful for programmatic scrolling
|
||||
*/
|
||||
export function useMessageListRef() {
|
||||
return useRef<VirtuosoHandle>(null);
|
||||
}
|
||||
|
||||
export type { VirtuosoHandle as MessageListHandle };
|
||||
97
src/components/chat/shared/date-utils.ts
Normal file
97
src/components/chat/shared/date-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Generic date utilities for chat
|
||||
*/
|
||||
import type { MessageListItem } from "./types";
|
||||
|
||||
/**
|
||||
* Format timestamp as a readable day marker
|
||||
*/
|
||||
export function formatDayMarker(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Reset time parts for comparison
|
||||
const dateOnly = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
);
|
||||
const todayOnly = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
const yesterdayOnly = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
);
|
||||
|
||||
if (dateOnly.getTime() === todayOnly.getTime()) {
|
||||
return "Today";
|
||||
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
// Format as "Jan 15" (short month, no year, respects locale)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two timestamps are on different days
|
||||
*/
|
||||
export function isDifferentDay(
|
||||
timestamp1: number,
|
||||
timestamp2: number,
|
||||
): boolean {
|
||||
const date1 = new Date(timestamp1 * 1000);
|
||||
const date2 = new Date(timestamp2 * 1000);
|
||||
|
||||
return (
|
||||
date1.getFullYear() !== date2.getFullYear() ||
|
||||
date1.getMonth() !== date2.getMonth() ||
|
||||
date1.getDate() !== date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process messages to include day markers
|
||||
* Returns array with messages and day markers interleaved
|
||||
*/
|
||||
export function insertDayMarkers<T extends { id: string; timestamp: number }>(
|
||||
messages: T[],
|
||||
): MessageListItem<T>[] {
|
||||
if (!messages || messages.length === 0) return [];
|
||||
|
||||
const items: MessageListItem<T>[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
// Add day marker if this is the first message or if day changed
|
||||
if (index === 0) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
} else {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (isDifferentDay(prevMessage.timestamp, message.timestamp)) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the message itself
|
||||
items.push({ type: "message", data: message });
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
27
src/components/chat/shared/index.ts
Normal file
27
src/components/chat/shared/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Generic chat components - Protocol-agnostic UI components for building chat interfaces
|
||||
* These components handle layout, virtualization, and basic interactions but don't know
|
||||
* about specific protocols (Nostr, Matrix, XMPP, etc.)
|
||||
*/
|
||||
|
||||
export { ChatWindow } from "./ChatWindow";
|
||||
export { ChatHeader } from "./ChatHeader";
|
||||
export { MessageList, useMessageListRef } from "./MessageList";
|
||||
export { MessageComposer, useMessageComposerRef } from "./MessageComposer";
|
||||
export { DayMarker } from "./DayMarker";
|
||||
|
||||
export {
|
||||
formatDayMarker,
|
||||
isDifferentDay,
|
||||
insertDayMarkers,
|
||||
} from "./date-utils";
|
||||
|
||||
export type {
|
||||
DisplayMessage,
|
||||
ChatHeaderInfo,
|
||||
ChatLoadingState,
|
||||
MessageListItem,
|
||||
} from "./types";
|
||||
|
||||
export type { MessageListHandle } from "./MessageList";
|
||||
export type { MessageComposerHandle } from "./MessageComposer";
|
||||
42
src/components/chat/shared/types.ts
Normal file
42
src/components/chat/shared/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Generic types for chat components
|
||||
* These types are protocol-agnostic and can be used for any chat implementation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic message type for display
|
||||
* Flexible base interface that can be extended by protocol-specific implementations
|
||||
*/
|
||||
export interface DisplayMessage {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
type?: "user" | "system" | "zap";
|
||||
replyTo?: string;
|
||||
metadata?: unknown; // Flexible to allow protocol-specific metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat header info
|
||||
*/
|
||||
export interface ChatHeaderInfo {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading states for chat
|
||||
*/
|
||||
export type ChatLoadingState = "idle" | "loading" | "error" | "success";
|
||||
|
||||
/**
|
||||
* Item in the message list (message or day marker)
|
||||
* Generic to support protocol-specific message types
|
||||
*/
|
||||
export type MessageListItem<
|
||||
T extends { id: string; timestamp: number } = DisplayMessage,
|
||||
> =
|
||||
| { type: "message"; data: T }
|
||||
| { type: "day-marker"; data: string; timestamp: number };
|
||||
Reference in New Issue
Block a user