feat(desktop): pin/unpin from message context menu + side panel
This commit is contained in:
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user