diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index 6556312..75eac9e 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -31,6 +31,7 @@ import { PencilIcon, PhoneIcon, PhoneOffIcon, + PinIcon, ReplyIcon, SmileIcon, SpinnerIcon, @@ -76,6 +77,10 @@ interface Props { onAvatarClick?: (userId: string, ev: React.MouseEvent) => void; /** Highlighted state — set briefly after a jump. */ highlighted?: boolean; + /** True iff this message is currently pinned (parent maintains the set). */ + isPinned?: boolean; + /** Right-click menu → toggle pin/unpin for this message id. */ + onTogglePin?: (messageId: string) => void; } export function MessageBubble({ @@ -97,6 +102,8 @@ export function MessageBubble({ onForward, onAvatarClick, highlighted = false, + isPinned = false, + onTogglePin, }: Props) { const { t } = useTranslation(['app']); const { session } = useAuth(); @@ -642,6 +649,15 @@ export function MessageBubble({ setContextMenu(null); void handleDelete(); }} + isPinned={isPinned} + {...(onTogglePin + ? { + onTogglePin: () => { + setContextMenu(null); + onTogglePin(message.id); + }, + } + : {})} /> )} @@ -739,6 +755,8 @@ function MessageContextMenu({ onEdit, onCopy, onDelete, + isPinned, + onTogglePin, }: { x: number; y: number; @@ -753,6 +771,8 @@ function MessageContextMenu({ onEdit: () => void; onCopy: () => void; onDelete: () => void; + isPinned?: boolean; + onTogglePin?: () => void; }) { useEffect(() => { function onKey(e: KeyboardEvent) { @@ -819,6 +839,13 @@ function MessageContextMenu({ tone="danger" /> )} + {onTogglePin && ( + } + onClick={onTogglePin} + /> + )} , document.body, ); diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 5186d44..61dbbd2 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -1,4 +1,4 @@ -import { parseMessagePayload } from '@chat-app/shared/chat'; +import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,6 +29,7 @@ import { CallPreviewPanel } from '../components/CallPreviewPanel'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; +import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel'; import { PollComposerDialog } from '../components/PollComposerDialog'; import { UserProfilePopover } from '../components/UserProfilePopover'; import { TypingIndicator } from '../components/TypingIndicator'; @@ -41,6 +42,7 @@ import { collectConversationAttachments, createPollPayload } from '../lib/conver import { compressImages } from '../lib/imageCompress'; import { ensureInstallId } from '../lib/installId'; import { searchCachedMessages } from '../lib/messageCache'; +import { supabase } from '../lib/supabase'; import type { OutboxItem } from '../lib/messageOutbox'; import { useConversationMessages } from '../lib/useConversationMessages'; import { useMessageReactions } from '../lib/useMessageReactions'; @@ -48,6 +50,7 @@ import { useGroupReceipts } from '../lib/useGroupReceipts'; import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries'; import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads'; import { usePeerPresence } from '../lib/usePeerPresence'; +import { usePinnedMessages } from '../lib/usePinnedMessages'; import { useTypingChannel } from '../lib/useTypingChannel'; const STICK_THRESHOLD = 80; @@ -177,6 +180,25 @@ export function ConversationPage() { } | null>(null); const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null); const [emojiOpen, setEmojiOpen] = useState(false); + const pins = usePinnedMessages(id); + const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); + const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); + + const handleTogglePin = useCallback( + async (messageId: string) => { + if (!id || !myId) return; + try { + if (pinnedIds.has(messageId)) { + await unpinMessage(supabase, id, messageId); + } else { + await pinMessage(supabase, id, messageId, myId); + } + } catch (err) { + console.warn('pin toggle failed', err); + } + }, + [id, myId, pinnedIds], + ); const scrollRef = useRef(null); const loadMoreSentinelRef = useRef(null); const fileInputRef = useRef(null); @@ -605,6 +627,8 @@ export function ConversationPage() { : {})} onSearchClick={() => setSearchOpen((v) => !v)} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} + pinnedCount={pins.length} + onOpenPinned={() => setPinnedPanelOpen(true)} /> )} @@ -757,6 +781,8 @@ export function ConversationPage() { }); }} highlighted={highlightedId === m.id} + isPinned={pinnedIds.has(m.id)} + onTogglePin={handleTogglePin} /> ); @@ -1068,6 +1094,17 @@ export function ConversationPage() { onClose={() => setProfilePopover(null)} /> )} + + setPinnedPanelOpen(false)} + onJump={(_messageId) => { + // Future: scroll to message. For now just close the panel. + setPinnedPanelOpen(false); + }} + onUnpin={(messageId) => void handleTogglePin(messageId)} + /> ); }