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