diff --git a/packages/web/src/components/chat/messageMenuItems.tsx b/packages/web/src/components/chat/messageMenuItems.tsx
new file mode 100644
index 00000000..534c781d
--- /dev/null
+++ b/packages/web/src/components/chat/messageMenuItems.tsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import type { ContextMenuItem } from '../../stores/contextMenuStore';
+import type { MessageWithUser } from '@backspace/shared';
+
+const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮'];
+
+interface MessageMenuParams {
+ message: MessageWithUser;
+ selectedText: string;
+ previousMessageId: string | null;
+ isAuthor: boolean;
+ isDm: boolean;
+ canAddReactions: boolean;
+ canSendMessages: boolean;
+ canManageMessages: boolean;
+ onReply: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onReaction: (emoji: string) => void;
+ onOpenEmojiPicker: () => void;
+ onMarkUnread: (messageId: string) => void;
+}
+
+export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuItem[] {
+ const {
+ message,
+ selectedText,
+ previousMessageId,
+ isAuthor,
+ isDm,
+ canAddReactions,
+ canSendMessages,
+ canManageMessages,
+ onReply,
+ onEdit,
+ onDelete,
+ onReaction,
+ onOpenEmojiPicker,
+ onMarkUnread,
+ } = params;
+
+ const items: ContextMenuItem[] = [];
+
+ // ── Quick Reaction Row ──────────────────────────────────────────────────
+ if (canAddReactions) {
+ items.push({
+ key: 'quick-reactions',
+ type: 'custom',
+ render: () => (
+
+ {QUICK_EMOJIS.map(emoji => (
+
+ ))}
+
+
+ ),
+ });
+ items.push({ key: 'reactions-sep', type: 'separator' });
+ }
+
+ // ── Reply ───────────────────────────────────────────────────────────────
+ const canReply = isDm || canSendMessages;
+ if (canReply) {
+ items.push({
+ key: 'reply',
+ type: 'action',
+ label: 'Reply',
+ icon: (
+
+ ),
+ onClick: onReply,
+ });
+ items.push({ key: 'reply-sep', type: 'separator' });
+ }
+
+ // ── Clipboard group ─────────────────────────────────────────────────────
+ items.push({
+ key: 'copy-selected',
+ type: 'action',
+ label: 'Copy Selected Text',
+ hidden: selectedText.length === 0,
+ icon: (
+
+ ),
+ onClick: () => {
+ navigator.clipboard.writeText(selectedText);
+ },
+ });
+
+ items.push({
+ key: 'copy-text',
+ type: 'action',
+ label: 'Copy Text',
+ hidden: !message.content,
+ icon: (
+
+ ),
+ onClick: () => {
+ navigator.clipboard.writeText(message.content ?? '');
+ },
+ });
+
+ items.push({ key: 'clipboard-sep', type: 'separator' });
+
+ // ── Mark Unread ─────────────────────────────────────────────────────────
+ items.push({
+ key: 'mark-unread',
+ type: 'action',
+ label: 'Mark Unread',
+ icon: (
+
+ ),
+ onClick: () => {
+ // '0' sentinel marks the entire channel as unread (when no previous message exists)
+ onMarkUnread(previousMessageId ?? '0');
+ },
+ });
+
+ items.push({ key: 'unread-sep', type: 'separator' });
+
+ // ── Edit Message (author only) ──────────────────────────────────────────
+ if (isAuthor) {
+ items.push({
+ key: 'edit',
+ type: 'action',
+ label: 'Edit Message',
+ icon: (
+
+ ),
+ onClick: onEdit,
+ });
+ }
+
+ // ── Delete Message (author or moderator) ────────────────────────────────
+ const canDelete = isAuthor || canManageMessages;
+ if (canDelete) {
+ items.push({
+ key: 'delete',
+ type: 'action',
+ label: 'Delete Message',
+ icon: (
+
+ ),
+ onClick: onDelete,
+ danger: true,
+ });
+ }
+
+ return items;
+}