feat(desktop): pin/unpin from message context menu + side panel

This commit is contained in:
byGalax
2026-05-16 17:51:01 +02:00
parent 6bff163f09
commit 3dbeabd268
2 changed files with 65 additions and 1 deletions
@@ -31,6 +31,7 @@ import {
PencilIcon, PencilIcon,
PhoneIcon, PhoneIcon,
PhoneOffIcon, PhoneOffIcon,
PinIcon,
ReplyIcon, ReplyIcon,
SmileIcon, SmileIcon,
SpinnerIcon, SpinnerIcon,
@@ -76,6 +77,10 @@ interface Props {
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void; onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
/** Highlighted state — set briefly after a jump. */ /** Highlighted state — set briefly after a jump. */
highlighted?: boolean; 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({ export function MessageBubble({
@@ -97,6 +102,8 @@ export function MessageBubble({
onForward, onForward,
onAvatarClick, onAvatarClick,
highlighted = false, highlighted = false,
isPinned = false,
onTogglePin,
}: Props) { }: Props) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { session } = useAuth(); const { session } = useAuth();
@@ -642,6 +649,15 @@ export function MessageBubble({
setContextMenu(null); setContextMenu(null);
void handleDelete(); void handleDelete();
}} }}
isPinned={isPinned}
{...(onTogglePin
? {
onTogglePin: () => {
setContextMenu(null);
onTogglePin(message.id);
},
}
: {})}
/> />
)} )}
</div> </div>
@@ -739,6 +755,8 @@ function MessageContextMenu({
onEdit, onEdit,
onCopy, onCopy,
onDelete, onDelete,
isPinned,
onTogglePin,
}: { }: {
x: number; x: number;
y: number; y: number;
@@ -753,6 +771,8 @@ function MessageContextMenu({
onEdit: () => void; onEdit: () => void;
onCopy: () => void; onCopy: () => void;
onDelete: () => void; onDelete: () => void;
isPinned?: boolean;
onTogglePin?: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -819,6 +839,13 @@ function MessageContextMenu({
tone="danger" tone="danger"
/> />
)} )}
{onTogglePin && (
<MenuItem
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
icon={<PinIcon className="h-4 w-4" />}
onClick={onTogglePin}
/>
)}
</div>, </div>,
document.body, document.body,
); );
+38 -1
View File
@@ -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 { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -29,6 +29,7 @@ import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog'; import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover'; import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator'; import { TypingIndicator } from '../components/TypingIndicator';
@@ -41,6 +42,7 @@ import { collectConversationAttachments, createPollPayload } from '../lib/conver
import { compressImages } from '../lib/imageCompress'; import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId'; import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache'; import { searchCachedMessages } from '../lib/messageCache';
import { supabase } from '../lib/supabase';
import type { OutboxItem } from '../lib/messageOutbox'; import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages'; import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions'; import { useMessageReactions } from '../lib/useMessageReactions';
@@ -48,6 +50,7 @@ import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries'; import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads'; import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence'; import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel'; import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80; const STICK_THRESHOLD = 80;
@@ -177,6 +180,25 @@ export function ConversationPage() {
} | null>(null); } | null>(null);
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null); const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false); 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<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null); const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -605,6 +627,8 @@ export function ConversationPage() {
: {})} : {})}
onSearchClick={() => setSearchOpen((v) => !v)} onSearchClick={() => setSearchOpen((v) => !v)}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
pinnedCount={pins.length}
onOpenPinned={() => setPinnedPanelOpen(true)}
/> />
)} )}
@@ -757,6 +781,8 @@ export function ConversationPage() {
}); });
}} }}
highlighted={highlightedId === m.id} highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin}
/> />
</li> </li>
); );
@@ -1068,6 +1094,17 @@ export function ConversationPage() {
onClose={() => setProfilePopover(null)} onClose={() => setProfilePopover(null)}
/> />
)} )}
<PinnedMessagesPanel
open={pinnedPanelOpen}
pins={pins}
onClose={() => setPinnedPanelOpen(false)}
onJump={(_messageId) => {
// Future: scroll to message. For now just close the panel.
setPinnedPanelOpen(false);
}}
onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
</div> </div>
); );
} }