From ac9472063dae3c6baa929cd47d7a43a63fffc504 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 09:12:29 +0000 Subject: [PATCH] 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 --- src/components/ChatViewer.tsx | 613 ++++++------------ src/components/chat/shared/ChatHeader.tsx | 32 + src/components/chat/shared/ChatWindow.tsx | 143 ++++ src/components/chat/shared/DayMarker.tsx | 18 + .../chat/shared/MessageComposer.tsx | 148 +++++ src/components/chat/shared/MessageList.tsx | 117 ++++ src/components/chat/shared/date-utils.ts | 97 +++ src/components/chat/shared/index.ts | 27 + src/components/chat/shared/types.ts | 42 ++ 9 files changed, 824 insertions(+), 413 deletions(-) create mode 100644 src/components/chat/shared/ChatHeader.tsx create mode 100644 src/components/chat/shared/ChatWindow.tsx create mode 100644 src/components/chat/shared/DayMarker.tsx create mode 100644 src/components/chat/shared/MessageComposer.tsx create mode 100644 src/components/chat/shared/MessageList.tsx create mode 100644 src/components/chat/shared/date-utils.ts create mode 100644 src/components/chat/shared/index.ts create mode 100644 src/components/chat/shared/types.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index ad7c931..baaf1ef 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -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(); @@ -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(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 ( -
- - Loading conversation... -
- ); - } + // Render function for messages (Nostr-specific) + const renderMessage = useCallback( + (message: Message, onScrollToMessage?: (messageId: string) => void) => ( + + ), + [adapter, conversation, handleReply, hasActiveAccount], + ); - // Handle error state with retry option - if (conversationResult.status === "error") { - return ( -
- - {conversationResult.error} - + + +
+ {/* Icon + Name */} +
+ {conversation.metadata?.icon && ( + { + // Hide image if it fails to load + e.currentTarget.style.display = "none"; + }} + /> + )} + {conversation.title} +
+ {/* Description */} + {conversation.metadata?.description && ( +

+ {conversation.metadata.description} +

+ )} + {/* Protocol Type - Clickable */} +
+ {(conversation.type === "group" || + conversation.type === "live-chat") && ( + + )} + {(conversation.type === "group" || + conversation.type === "live-chat") && ( + + )} + + {conversation.type} + +
+ {/* Live Activity Status */} + {liveActivity?.status && ( +
+ Status: + +
+ )} + {/* Host Info */} + {liveActivity?.hostPubkey && ( +
+ Host: + +
+ )} +
+
+ + + {/* Copy Chat ID button */} + {getChatIdentifier(conversation) && ( + -
- ); - } + {chatIdCopied ? ( + + ) : ( + + )} + + )} + + ); - // 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 && ( + <> + + + {(conversation.type === "group" || conversation.type === "live-chat") && ( + + )} + + ); + + // Reply preview for composer + const replyPreview = replyTo ? ( + setReplyTo(undefined)} + /> + ) : undefined; return ( -
- {/* Header with conversation info and controls */} -
-
-
- {headerPrefix} - - - - - - -
- {/* Icon + Name */} -
- {conversation.metadata?.icon && ( - { - // Hide image if it fails to load - e.currentTarget.style.display = "none"; - }} - /> - )} - - {conversation.title} - -
- {/* Description */} - {conversation.metadata?.description && ( -

- {conversation.metadata.description} -

- )} - {/* Protocol Type - Clickable */} -
- {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} - {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} - - {conversation.type} - -
- {/* Live Activity Status */} - {liveActivity?.status && ( -
- - Status: - - -
- )} - {/* Host Info */} - {liveActivity?.hostPubkey && ( -
- Host: - -
- )} -
-
-
-
- {/* Copy Chat ID button */} - {getChatIdentifier(conversation) && ( - - )} -
-
- - - {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} -
-
-
- - {/* Message timeline with virtualization */} -
- {messagesWithMarkers && messagesWithMarkers.length > 0 ? ( - - hasMore && conversationResult.status === "success" ? ( -
- -
- ) : null, - Footer: () =>
, - }} - itemContent={(_index, item) => { - if (item.type === "day-marker") { - return ( -
- -
- ); - } - return ( - - ); - }} - style={{ height: "100%" }} - /> - ) : ( -
- No messages yet. Start the conversation! -
- )} -
- - {/* Message composer - only show if user has active account */} - {hasActiveAccount ? ( -
- {replyTo && ( - setReplyTo(undefined)} - /> - )} -
- - - - - - -

Attach media

-
-
-
- { - if (content.trim()) { - handleSend(content, replyTo, emojiTags, blobAttachments); - } - }} - className="flex-1 min-w-0" - /> - -
- {uploadDialog} -
- ) : ( -
- Sign in to send messages -
- )} -
+ 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, + onSearchEmojis: searchEmojis as (query: string) => Promise, + onSearchCommands: searchCommands as ( + query: string, + ) => Promise, + onCommandExecute: handleCommandExecute as ( + command: unknown, + ) => Promise, + onSubmit: (content, emojiTags, blobAttachments) => { + if (content.trim()) { + handleSend(content, replyTo, emojiTags, blobAttachments); + } + }, + onAttach: openUpload, + attachDialog: uploadDialog, + }} + /> ); } diff --git a/src/components/chat/shared/ChatHeader.tsx b/src/components/chat/shared/ChatHeader.tsx new file mode 100644 index 0000000..4349dda --- /dev/null +++ b/src/components/chat/shared/ChatHeader.tsx @@ -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 ( +
+
+
+ {prefix} + {children} +
+ {suffix && ( +
+ {suffix} +
+ )} +
+
+ ); +} diff --git a/src/components/chat/shared/ChatWindow.tsx b/src/components/chat/shared/ChatWindow.tsx new file mode 100644 index 0000000..ea14525 --- /dev/null +++ b/src/components/chat/shared/ChatWindow.tsx @@ -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 { + /** 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[]; + /** 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; + onSearchEmojis?: (query: string) => Promise; + onSearchCommands?: (query: string) => Promise; + onCommandExecute?: (command: unknown) => void | Promise; + 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({ + loadingState, + errorMessage, + onRetry, + header, + headerPrefix, + headerSuffix, + messages, + renderMessage, + emptyState, + hasMore, + isLoadingMore, + onLoadMore, + composer, +}: ChatWindowProps) { + // Loading state + if (loadingState === "loading") { + return ( +
+ + Loading conversation... +
+ ); + } + + // Error state with retry + if (loadingState === "error") { + return ( +
+ + + {errorMessage || "Failed to load conversation"} + + {onRetry && ( + + )} +
+ ); + } + + // Success state - show chat interface + return ( +
+ {/* Header */} + + {header} + + + {/* Message list */} +
+ +
+ + {/* Message composer */} + +
+ ); +} diff --git a/src/components/chat/shared/DayMarker.tsx b/src/components/chat/shared/DayMarker.tsx new file mode 100644 index 0000000..9cb8be9 --- /dev/null +++ b/src/components/chat/shared/DayMarker.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/chat/shared/MessageComposer.tsx b/src/components/chat/shared/MessageComposer.tsx new file mode 100644 index 0000000..5921979 --- /dev/null +++ b/src/components/chat/shared/MessageComposer.tsx @@ -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; + /** Search function for : emoji autocomplete */ + onSearchEmojis?: (query: string) => Promise; + /** Search function for / command autocomplete */ + onSearchCommands?: (query: string) => Promise; + /** Called when a command is executed from autocomplete */ + onCommandExecute?: (command: unknown) => void | Promise; + /** 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(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 ( +
+ {disabledMessage} +
+ ); + } + + return ( +
+ {replyPreview} +
+ {onAttach && ( + + + + + + +

Attach media

+
+
+
+ )} + + +
+ {attachDialog} +
+ ); +} + +/** + * Hook to expose editor ref to parent components + * Useful for programmatic control (focus, insert, etc.) + */ +export function useMessageComposerRef() { + return useRef(null); +} + +export type { MentionEditorHandle as MessageComposerHandle }; diff --git a/src/components/chat/shared/MessageList.tsx b/src/components/chat/shared/MessageList.tsx new file mode 100644 index 0000000..95979de --- /dev/null +++ b/src/components/chat/shared/MessageList.tsx @@ -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 { + /** Items to render (messages + day markers) */ + items: MessageListItem[]; + /** 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({ + items, + renderMessage, + hasMore = false, + isLoading = false, + onLoadMore, + emptyState, +}: MessageListProps) { + const virtuosoRef = useRef(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 ( +
+ {emptyState || "No messages yet. Start the conversation!"} +
+ ); + } + + return ( + + hasMore && onLoadMore ? ( +
+ +
+ ) : null, + Footer: () =>
, + }} + itemContent={(_index, item) => { + if (item.type === "day-marker") { + return ; + } + 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(null); +} + +export type { VirtuosoHandle as MessageListHandle }; diff --git a/src/components/chat/shared/date-utils.ts b/src/components/chat/shared/date-utils.ts new file mode 100644 index 0000000..1de27ae --- /dev/null +++ b/src/components/chat/shared/date-utils.ts @@ -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( + messages: T[], +): MessageListItem[] { + if (!messages || messages.length === 0) return []; + + const items: MessageListItem[] = []; + + 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; +} diff --git a/src/components/chat/shared/index.ts b/src/components/chat/shared/index.ts new file mode 100644 index 0000000..44034ff --- /dev/null +++ b/src/components/chat/shared/index.ts @@ -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"; diff --git a/src/components/chat/shared/types.ts b/src/components/chat/shared/types.ts new file mode 100644 index 0000000..31ea0a3 --- /dev/null +++ b/src/components/chat/shared/types.ts @@ -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 };