feat(desktop): pin/unpin from message context menu + side panel
This commit is contained in:
@@ -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);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 && (
|
||||
<MenuItem
|
||||
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
|
||||
icon={<PinIcon className="h-4 w-4" />}
|
||||
onClick={onTogglePin}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -1068,6 +1094,17 @@ export function ConversationPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user