feat(pins): pin messages to a channel
Pinned state lives on the message rather than a join table: a message is pinned in exactly one channel, its own, so a separate table would add a join to every lookup and buy nothing. Every message now carries its pin state, so the timeline can mark a pin without a second request and the panel and the timeline cannot disagree. Toggling is deliberately not optimistic — the server refuses past the channel's limit, and showing it pinned before confirmation would lie in exactly that case. The pins list reuses the same assembly the channel history uses, extracted into one helper, so the two cannot drift apart in what they include. The migration also adds the (channel, user, created) index the filtered search will need, since both touch the same table and one migration is cheaper than two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useT } from '../../i18n';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
||||
@@ -331,6 +332,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
setEditContent(message.content ?? '');
|
||||
setIsEditing(true);
|
||||
},
|
||||
isPinned: Boolean((message as MessageWithUser).pinnedAt),
|
||||
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
|
||||
onTogglePin: () => {
|
||||
const pinned = Boolean((message as MessageWithUser).pinnedAt);
|
||||
// Sem atualização otimista: o servidor recusa acima do limite do canal,
|
||||
// e mostrar como fixada antes da confirmação mentiria nesse caso.
|
||||
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
|
||||
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
|
||||
},
|
||||
onDelete: () => deleteMessage(message.id, channelKey),
|
||||
onReaction: (emoji: string) => toggleReaction(emoji),
|
||||
onOpenEmojiPicker: () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MessageWithUser } from '@backspace/shared';
|
||||
import { api } from '../../api/client';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface PinsPopoverProps {
|
||||
channelId: string;
|
||||
onClose: () => void;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function PinsPopover({ channelId, onClose, onJumpToMessage }: PinsPopoverProps) {
|
||||
const t = useT();
|
||||
const [messages, setMessages] = useState<MessageWithUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.pins.list(channelId)
|
||||
.then(({ messages: list }) => { if (!cancelled) setMessages(list); })
|
||||
.catch(() => { if (!cancelled) setMessages([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointer = (e: MouseEvent | TouchEvent) => {
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
// touchstart junto de mousedown: o iOS Safari não sintetiza mousedown de
|
||||
// toque de forma confiável, como os outros popovers deste projeto tratam.
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('touchstart', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('touchstart', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 top-full mt-2 z-[300] w-[380px] max-h-[460px] glass rounded-xl overflow-hidden flex flex-col shadow-xl"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-border-soft shrink-0">
|
||||
<span className="text-[13px] font-semibold text-txt-primary">{t('pins.title')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-2">
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-14 rounded-lg bg-surface-elevated animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-[12px] text-txt-tertiary p-3">{t('pins.empty')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{messages.map((m) => (
|
||||
<li key={m.id}>
|
||||
<button
|
||||
onClick={() => { onJumpToMessage(m.id); onClose(); }}
|
||||
className="w-full text-left flex gap-2.5 p-2 rounded-lg hover:bg-interactive-hover transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
src={m.user.avatar}
|
||||
name={m.user.displayName ?? m.user.username}
|
||||
size={28}
|
||||
userId={m.user.id}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] font-semibold text-txt-primary truncate">
|
||||
{m.user.displayName ?? m.user.username}
|
||||
</div>
|
||||
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">
|
||||
{m.content || ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@ interface MessageMenuParams {
|
||||
canAddReactions: boolean;
|
||||
canSendMessages: boolean;
|
||||
canManageMessages: boolean;
|
||||
isPinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
/** Rótulos traduzidos: este módulo não é um componente e não pode usar o hook. */
|
||||
labels: { pin: string; unpin: string };
|
||||
onReply: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -42,6 +46,9 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
||||
canAddReactions,
|
||||
canSendMessages,
|
||||
canManageMessages,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
labels,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -306,6 +313,21 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pin / Unpin (moderator) ─────────────────────────────────────────────
|
||||
if (canManageMessages) {
|
||||
items.push({
|
||||
key: 'pin',
|
||||
type: 'action',
|
||||
label: isPinned ? labels.unpin : labels.pin,
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: onTogglePin,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Delete Message (author or moderator) ────────────────────────────────
|
||||
const canDelete = isAuthor || canManageMessages;
|
||||
if (canDelete) {
|
||||
|
||||
Reference in New Issue
Block a user