mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
💄 style: add emoji reaction feature for messages (#12004)
* ✨ feat: add emoji reaction feature for messages - Add EmojiReaction type definition in metadata - Add addReaction/removeReaction store actions in Conversation Store - Create ReactionDisplay component for showing reactions - Create ReactionPicker component with quick emoji selection - Add ReactionFeedbackProcessor for context-engine - Integrate reaction UI into Assistant message component - Add i18n keys for reaction feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * update ui * refactor reaction implement * add reaction processor * add reaction processor * push --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e0e158c586
commit
a83dc4d4ed
25 changed files with 785 additions and 12 deletions
|
|
@ -165,6 +165,7 @@
|
|||
"messageAction.delAndRegenerate": "Delete and Regenerate",
|
||||
"messageAction.deleteDisabledByThreads": "This message has a subtopic and can’t be deleted",
|
||||
"messageAction.expand": "Expand Message",
|
||||
"messageAction.reaction": "Add Reaction",
|
||||
"messageAction.regenerate": "Regenerate",
|
||||
"messages.dm.sentTo": "Visible only to {{name}}",
|
||||
"messages.dm.title": "DM",
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@
|
|||
"messageAction.delAndRegenerate": "删除并重新生成",
|
||||
"messageAction.deleteDisabledByThreads": "该消息有子话题,无法删除",
|
||||
"messageAction.expand": "展开消息",
|
||||
"messageAction.reaction": "添加表情",
|
||||
"messageAction.regenerate": "重新生成",
|
||||
"messages.dm.sentTo": "仅对 {{name}} 可见",
|
||||
"messages.dm.title": "私信",
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@
|
|||
"@codesandbox/sandpack-react": "^2.20.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fal-ai/client": "^1.8.4",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
MessageCleanupProcessor,
|
||||
MessageContentProcessor,
|
||||
PlaceholderVariablesProcessor,
|
||||
ReactionFeedbackProcessor,
|
||||
SupervisorRoleRestoreProcessor,
|
||||
TaskMessageProcessor,
|
||||
TasksFlattenProcessor,
|
||||
|
|
@ -299,6 +300,8 @@ export class MessagesEngine {
|
|||
// =============================================
|
||||
// Phase 5: Content Processing
|
||||
// =============================================
|
||||
// 22. Reaction feedback injection (append user reaction feedback to assistant messages)
|
||||
new ReactionFeedbackProcessor({ enabled: true }),
|
||||
|
||||
// 22. Message content processing (image encoding, etc.)
|
||||
new MessageContentProcessor({
|
||||
|
|
|
|||
|
|
@ -218,6 +218,10 @@ export interface MessagesEngineParams {
|
|||
groupAgentBuilderContext?: GroupAgentBuilderContext;
|
||||
/** GTD (Getting Things Done) configuration */
|
||||
gtd?: GTDConfig;
|
||||
/** Reaction feedback configuration */
|
||||
reactionFeedback?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
/** User memory configuration */
|
||||
userMemory?: UserMemoryConfig;
|
||||
|
||||
|
|
|
|||
77
packages/context-engine/src/processors/ReactionFeedback.ts
Normal file
77
packages/context-engine/src/processors/ReactionFeedback.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { EmojiReaction } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { BaseProcessor } from '../base/BaseProcessor';
|
||||
import type { PipelineContext, ProcessorOptions } from '../types';
|
||||
|
||||
const log = debug('context-engine:processor:ReactionFeedbackProcessor');
|
||||
|
||||
export interface ReactionFeedbackConfig {
|
||||
/** Whether to enable reaction feedback injection */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction Feedback Processor
|
||||
* Converts emoji reactions on assistant messages to feedback text
|
||||
* and injects into the next user message, where the model will actually attend to it.
|
||||
*/
|
||||
export class ReactionFeedbackProcessor extends BaseProcessor {
|
||||
readonly name = 'ReactionFeedbackProcessor';
|
||||
|
||||
constructor(
|
||||
private config: ReactionFeedbackConfig = {},
|
||||
options: ProcessorOptions = {},
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
|
||||
if (!this.config.enabled) {
|
||||
return this.markAsExecuted(context);
|
||||
}
|
||||
|
||||
const clonedContext = this.cloneContext(context);
|
||||
let processedCount = 0;
|
||||
|
||||
// Collect emojis from assistant messages, then inject into the next user message
|
||||
let pendingEmojis: string[] = [];
|
||||
|
||||
for (let i = 0; i < clonedContext.messages.length; i++) {
|
||||
const message = clonedContext.messages[i];
|
||||
|
||||
// Collect reactions from assistant messages
|
||||
if (message.role === 'assistant' && message.metadata?.reactions) {
|
||||
const reactions = message.metadata.reactions as EmojiReaction[];
|
||||
const emojis = reactions.map((r) => r.emoji).filter(Boolean);
|
||||
|
||||
if (emojis.length > 0) {
|
||||
pendingEmojis.push(...emojis);
|
||||
log(`Collected reaction emojis from message ${message.id}: ${emojis.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject accumulated feedback into the next user message
|
||||
if (message.role === 'user' && pendingEmojis.length > 0) {
|
||||
const originalContent = message.content;
|
||||
|
||||
if (typeof originalContent === 'string') {
|
||||
const feedbackTag = `[User Feedback Emoji: ${pendingEmojis.join(' ')}]`;
|
||||
|
||||
clonedContext.messages[i] = {
|
||||
...message,
|
||||
content: `${feedbackTag}\n\n${originalContent}`,
|
||||
};
|
||||
processedCount += pendingEmojis.length;
|
||||
}
|
||||
|
||||
pendingEmojis = [];
|
||||
}
|
||||
}
|
||||
|
||||
clonedContext.metadata.reactionFeedbackProcessed = processedCount;
|
||||
log(`Reaction feedback processing completed, processed ${processedCount} messages`);
|
||||
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ReactionFeedbackProcessor } from '../ReactionFeedback';
|
||||
|
||||
const createContext = (messages: any[]) => ({
|
||||
initialState: {
|
||||
messages: [],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
systemRole: '',
|
||||
tools: [],
|
||||
},
|
||||
isAborted: false,
|
||||
messages,
|
||||
metadata: {
|
||||
maxTokens: 4096,
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
const createMessage = (
|
||||
id: string,
|
||||
role: string,
|
||||
content: string | any[],
|
||||
reactions?: { count: number; emoji: string; users: string[] }[],
|
||||
) => ({
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
id,
|
||||
role,
|
||||
updatedAt: Date.now(),
|
||||
...(reactions ? { metadata: { reactions } } : {}),
|
||||
});
|
||||
|
||||
describe('ReactionFeedbackProcessor', () => {
|
||||
it('should skip processing when disabled', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: false });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'Hello', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
createMessage('2', 'user', 'Thanks'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Hello');
|
||||
expect(result.messages[1].content).toBe('Thanks');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should inject feedback emoji into the next user message', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'user', 'Hi'),
|
||||
createMessage('2', 'assistant', 'Hello!', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
createMessage('3', 'user', 'Tell me more'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Hi');
|
||||
expect(result.messages[1].content).toBe('Hello!');
|
||||
expect(result.messages[2].content).toBe('[User Feedback Emoji: 👍]\n\nTell me more');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle multiple reactions on one assistant message', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'Great answer', [
|
||||
{ count: 1, emoji: '👍', users: ['user1'] },
|
||||
{ count: 1, emoji: '🚀', users: ['user1'] },
|
||||
]),
|
||||
createMessage('2', 'user', 'Continue'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[1].content).toBe('[User Feedback Emoji: 👍 🚀]\n\nContinue');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(2);
|
||||
});
|
||||
|
||||
it('should accumulate emojis from multiple assistant messages into one user message', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'First response', [
|
||||
{ count: 1, emoji: '👍', users: ['user1'] },
|
||||
]),
|
||||
createMessage('2', 'assistant', 'Second response', [
|
||||
{ count: 1, emoji: '👎', users: ['user1'] },
|
||||
]),
|
||||
createMessage('3', 'user', 'Next question'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[2].content).toBe('[User Feedback Emoji: 👍 👎]\n\nNext question');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(2);
|
||||
});
|
||||
|
||||
it('should not modify assistant messages', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'Response with reaction', [
|
||||
{ count: 1, emoji: '❤️', users: ['user1'] },
|
||||
]),
|
||||
createMessage('2', 'user', 'Follow up'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Response with reaction');
|
||||
});
|
||||
|
||||
it('should skip assistant messages without reactions', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'No reactions here'),
|
||||
createMessage('2', 'user', 'Reply'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[1].content).toBe('Reply');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(0);
|
||||
});
|
||||
|
||||
it('should discard pending feedback when no following user message exists', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'user', 'Question'),
|
||||
createMessage('2', 'assistant', 'Answer', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Question');
|
||||
expect(result.messages[1].content).toBe('Answer');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip non-string user message content', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'Response', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
createMessage('2', 'user', [{ text: 'array content', type: 'text' }]),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[1].content).toEqual([{ text: 'array content', type: 'text' }]);
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(0);
|
||||
});
|
||||
|
||||
it('should reset pending emojis after injection', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'assistant', 'First', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
createMessage('2', 'user', 'Second question'),
|
||||
createMessage('3', 'assistant', 'Third'),
|
||||
createMessage('4', 'user', 'Fourth question'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[1].content).toBe('[User Feedback Emoji: 👍]\n\nSecond question');
|
||||
expect(result.messages[3].content).toBe('Fourth question');
|
||||
});
|
||||
|
||||
it('should handle mixed conversation with multiple feedback injection points', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const context = createContext([
|
||||
createMessage('1', 'user', 'Q1'),
|
||||
createMessage('2', 'assistant', 'A1', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
createMessage('3', 'user', 'Q2'),
|
||||
createMessage('4', 'assistant', 'A2', [{ count: 1, emoji: '👎', users: ['user1'] }]),
|
||||
createMessage('5', 'user', 'Q3'),
|
||||
]);
|
||||
|
||||
const result = await processor.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Q1');
|
||||
expect(result.messages[2].content).toBe('[User Feedback Emoji: 👍]\n\nQ2');
|
||||
expect(result.messages[4].content).toBe('[User Feedback Emoji: 👎]\n\nQ3');
|
||||
expect(result.metadata.reactionFeedbackProcessed).toBe(2);
|
||||
});
|
||||
|
||||
it('should not mutate the original context', async () => {
|
||||
const processor = new ReactionFeedbackProcessor({ enabled: true });
|
||||
|
||||
const originalMessages = [
|
||||
createMessage('1', 'assistant', 'Response', [{ count: 1, emoji: '👍', users: ['user1'] }]),
|
||||
createMessage('2', 'user', 'Follow up'),
|
||||
];
|
||||
const context = createContext(originalMessages);
|
||||
|
||||
await processor.process(context);
|
||||
|
||||
expect(context.messages[1].content).toBe('Follow up');
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ export {
|
|||
} from './PlaceholderVariables';
|
||||
export { SupervisorRoleRestoreProcessor } from './SupervisorRoleRestore';
|
||||
export { TaskMessageProcessor } from './TaskMessage';
|
||||
export { ReactionFeedbackProcessor } from './ReactionFeedback';
|
||||
export { TasksFlattenProcessor } from './TasksFlatten';
|
||||
export { ToolCallProcessor } from './ToolCall';
|
||||
export { ToolMessageReorder } from './ToolMessageReorder';
|
||||
|
|
@ -34,5 +35,6 @@ export type {
|
|||
PlaceholderValueMap,
|
||||
PlaceholderVariablesConfig,
|
||||
} from './PlaceholderVariables';
|
||||
export type { ReactionFeedbackConfig } from './ReactionFeedback';
|
||||
export type { TaskMessageConfig } from './TaskMessage';
|
||||
export type { ToolCallConfig } from './ToolCall';
|
||||
|
|
|
|||
|
|
@ -77,12 +77,27 @@ export const ModelPerformanceSchema = z.object({
|
|||
latency: z.number().optional(),
|
||||
});
|
||||
|
||||
// ============ Emoji Reaction ============ //
|
||||
|
||||
export interface EmojiReaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
}
|
||||
|
||||
export const EmojiReactionSchema = z.object({
|
||||
emoji: z.string(),
|
||||
count: z.number(),
|
||||
users: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSchema).extend({
|
||||
collapsed: z.boolean().optional(),
|
||||
inspectExpanded: z.boolean().optional(),
|
||||
isMultimodal: z.boolean().optional(),
|
||||
isSupervisor: z.boolean().optional(),
|
||||
pageSelections: z.array(PageSelectionSchema).optional(),
|
||||
reactions: z.array(EmojiReactionSchema).optional(),
|
||||
});
|
||||
|
||||
export interface ModelUsage extends ModelTokensUsage {
|
||||
|
|
@ -155,4 +170,8 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
|||
* Used for Ask AI functionality to persist selection context
|
||||
*/
|
||||
pageSelections?: PageSelection[];
|
||||
/**
|
||||
* Emoji reactions on this message
|
||||
*/
|
||||
reactions?: EmojiReaction[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const MainChatInput = memo(() => {
|
|||
}}
|
||||
rightActions={rightActions}
|
||||
sendMenu={{ items: sendMenuItems }}
|
||||
skipScrollMarginWithList
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const MainChatInput = memo(() => {
|
|||
}}
|
||||
rightActions={rightActions}
|
||||
sendMenu={{ items: sendMenuItems }}
|
||||
skipScrollMarginWithList
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ export interface ChatInputProps {
|
|||
* Send menu configuration (for send options like Enter/Cmd+Enter, Add AI/User message)
|
||||
*/
|
||||
sendMenu?: MenuProps;
|
||||
/**
|
||||
* 与 ChatList 共同挨在一起的时候,将一点间距去掉
|
||||
*/
|
||||
skipScrollMarginWithList?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +64,7 @@ const ChatInput = memo<ChatInputProps>(
|
|||
sendMenu,
|
||||
sendButtonProps: customSendButtonProps,
|
||||
onEditorReady,
|
||||
skipScrollMarginWithList,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
|
|
@ -132,7 +137,7 @@ const ChatInput = memo<ChatInputProps>(
|
|||
};
|
||||
|
||||
const defaultContent = (
|
||||
<WideScreenContainer>
|
||||
<WideScreenContainer style={skipScrollMarginWithList ? { marginTop: -12 } : undefined}>
|
||||
{sendMessageErrorMsg && (
|
||||
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
|
||||
<Alert
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { type UIChatMessage } from '@lobechat/types';
|
||||
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
|
||||
import { ActionIconGroup, Flexbox, createRawModal } from '@lobehub/ui';
|
||||
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import { ReactionPicker } from '../../../components/Reaction';
|
||||
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
|
||||
import {
|
||||
Provider,
|
||||
|
|
@ -209,7 +210,12 @@ export const AssistantActionsBar = memo<AssistantActionsBarProps>(
|
|||
|
||||
if (error) return <ErrorActionsBar actions={defaultActions} onActionClick={handleAction} />;
|
||||
|
||||
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
|
||||
return (
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
<ReactionPicker messageId={id} />
|
||||
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { LOADING_FLAT } from '@lobechat/const';
|
||||
import { type UIChatMessage } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { ReactionDisplay } from '../../../components/Reaction';
|
||||
import { messageStateSelectors, useConversationStore } from '../../../store';
|
||||
import { CollapsedMessage } from '../../AssistantGroup/components/CollapsedMessage';
|
||||
import DisplayContent from '../../components/DisplayContent';
|
||||
|
|
@ -19,6 +23,9 @@ const MessageContent = memo<UIChatMessage>(
|
|||
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
|
||||
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
|
||||
const isReasoning = useConversationStore(messageStateSelectors.isMessageInReasoning(id));
|
||||
const addReaction = useConversationStore((s) => s.addReaction);
|
||||
const removeReaction = useConversationStore((s) => s.removeReaction);
|
||||
const userId = useUserStore(userProfileSelectors.userId)!;
|
||||
|
||||
const isToolCallGenerating = generating && (content === LOADING_FLAT || !content) && !!tools;
|
||||
|
||||
|
|
@ -33,6 +40,28 @@ const MessageContent = memo<UIChatMessage>(
|
|||
|
||||
const showFileChunks = !!chunksList && chunksList.length > 0;
|
||||
|
||||
const reactions = metadata?.reactions || [];
|
||||
|
||||
const handleReactionClick = useCallback(
|
||||
(emoji: string) => {
|
||||
const existing = reactions.find((r) => r.emoji === emoji);
|
||||
if (existing && existing.users.includes(userId)) {
|
||||
removeReaction(id, emoji);
|
||||
} else {
|
||||
addReaction(id, emoji);
|
||||
}
|
||||
},
|
||||
[id, reactions, addReaction, removeReaction],
|
||||
);
|
||||
|
||||
const isActive = useCallback(
|
||||
(emoji: string) => {
|
||||
const reaction = reactions.find((r) => r.emoji === emoji);
|
||||
return !!reaction && reaction.users.includes(userId);
|
||||
},
|
||||
[reactions],
|
||||
);
|
||||
|
||||
if (isCollapsed) return <CollapsedMessage content={content} id={id} />;
|
||||
|
||||
return (
|
||||
|
|
@ -52,6 +81,14 @@ const MessageContent = memo<UIChatMessage>(
|
|||
tempDisplayContent={metadata?.tempDisplayContent}
|
||||
/>
|
||||
{showImageItems && <ImageFileListViewer items={imageList} />}
|
||||
{reactions.length > 0 && (
|
||||
<ReactionDisplay
|
||||
isActive={isActive}
|
||||
messageId={id}
|
||||
onReactionClick={handleReactionClick}
|
||||
reactions={reactions}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
|
||||
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
|
||||
import { ActionIconGroup, Flexbox, createRawModal } from '@lobehub/ui';
|
||||
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import { ReactionPicker } from '../../../components/Reaction';
|
||||
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
|
||||
import {
|
||||
Provider,
|
||||
|
|
@ -162,7 +163,12 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
|
|||
[allActions],
|
||||
);
|
||||
|
||||
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
|
||||
return (
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
<ReactionPicker messageId={id} />
|
||||
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
WithContentId.displayName = 'GroupActionsWithContentId';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import type { AssistantContentBlock } from '@lobechat/types';
|
||||
import type { AssistantContentBlock, EmojiReaction } from '@lobechat/types';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { type MouseEventHandler, Suspense, memo, useCallback, useMemo } from 'react';
|
||||
|
||||
|
|
@ -12,7 +12,10 @@ import dynamic from '@/libs/next/dynamic';
|
|||
import { useAgentStore } from '@/store/agent';
|
||||
import { builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { ReactionDisplay } from '../../components/Reaction';
|
||||
import { useAgentMeta } from '../../hooks';
|
||||
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
|
||||
import {
|
||||
|
|
@ -45,7 +48,8 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||
// Get message and actionsConfig from ConversationStore
|
||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||
|
||||
const { agentId, usage, createdAt, children, performance, model, provider, branch } = item;
|
||||
const { agentId, usage, createdAt, children, performance, model, provider, branch, metadata } =
|
||||
item;
|
||||
const avatar = useAgentMeta(agentId);
|
||||
|
||||
// Collect fileList from all children blocks
|
||||
|
|
@ -75,6 +79,31 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||
messageId: id,
|
||||
});
|
||||
|
||||
const addReaction = useConversationStore((s) => s.addReaction);
|
||||
const removeReaction = useConversationStore((s) => s.removeReaction);
|
||||
const userId = useUserStore(userProfileSelectors.userId)!;
|
||||
const reactions: EmojiReaction[] = metadata?.reactions || [];
|
||||
|
||||
const handleReactionClick = useCallback(
|
||||
(emoji: string) => {
|
||||
const existing = reactions.find((r) => r.emoji === emoji);
|
||||
if (existing && existing.users.includes(userId)) {
|
||||
removeReaction(id, emoji);
|
||||
} else {
|
||||
addReaction(id, emoji);
|
||||
}
|
||||
},
|
||||
[id, reactions, addReaction, removeReaction],
|
||||
);
|
||||
|
||||
const isReactionActive = useCallback(
|
||||
(emoji: string) => {
|
||||
const reaction = reactions.find((r) => r.emoji === emoji);
|
||||
return !!reaction && reaction.users.includes(userId);
|
||||
},
|
||||
[reactions],
|
||||
);
|
||||
|
||||
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
|
||||
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
|
||||
|
||||
|
|
@ -143,6 +172,14 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||
{model && (
|
||||
<Usage model={model} performance={performance} provider={provider!} usage={usage} />
|
||||
)}
|
||||
{reactions.length > 0 && (
|
||||
<ReactionDisplay
|
||||
isActive={isReactionActive}
|
||||
messageId={id}
|
||||
onReactionClick={handleReactionClick}
|
||||
reactions={reactions}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{editing && contentId && <EditState content={lastAssistantMsg?.content} id={contentId} />}
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
|
||||
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
|
||||
import { ActionIconGroup, Flexbox, createRawModal } from '@lobehub/ui';
|
||||
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import { ReactionPicker } from '../../../components/Reaction';
|
||||
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
|
||||
import {
|
||||
Provider,
|
||||
|
|
@ -154,7 +155,12 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
|
|||
[allActions],
|
||||
);
|
||||
|
||||
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
|
||||
return (
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
<ReactionPicker messageId={id} />
|
||||
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
WithContentId.displayName = 'GroupActionsWithContentId';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import type { EmojiReaction } from '@lobechat/types';
|
||||
import { Tag } from '@lobehub/ui';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { type MouseEventHandler, memo, useCallback } from 'react';
|
||||
|
|
@ -11,7 +12,10 @@ import { ChatItem } from '@/features/Conversation/ChatItem';
|
|||
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { ReactionDisplay } from '../../components/Reaction';
|
||||
import { useAgentMeta } from '../../hooks';
|
||||
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
|
||||
import {
|
||||
|
|
@ -41,7 +45,8 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||
// Get message and actionsConfig from ConversationStore
|
||||
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
|
||||
|
||||
const { agentId, usage, createdAt, children, performance, model, provider, branch } = item;
|
||||
const { agentId, usage, createdAt, children, performance, model, provider, branch, metadata } =
|
||||
item;
|
||||
const avatar = useAgentMeta(agentId);
|
||||
|
||||
// Get group member avatars for GroupAvatar
|
||||
|
|
@ -62,6 +67,31 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||
messageId: id,
|
||||
});
|
||||
|
||||
const addReaction = useConversationStore((s) => s.addReaction);
|
||||
const removeReaction = useConversationStore((s) => s.removeReaction);
|
||||
const userId = useUserStore(userProfileSelectors.userId)!;
|
||||
const reactions: EmojiReaction[] = metadata?.reactions || [];
|
||||
|
||||
const handleReactionClick = useCallback(
|
||||
(emoji: string) => {
|
||||
const existing = reactions.find((r) => r.emoji === emoji);
|
||||
if (existing && existing.users.includes(userId)) {
|
||||
removeReaction(id, emoji);
|
||||
} else {
|
||||
addReaction(id, emoji);
|
||||
}
|
||||
},
|
||||
[id, reactions, addReaction, removeReaction],
|
||||
);
|
||||
|
||||
const isReactionActive = useCallback(
|
||||
(emoji: string) => {
|
||||
const reaction = reactions.find((r) => r.emoji === emoji);
|
||||
return !!reaction && reaction.users.includes(userId);
|
||||
},
|
||||
[reactions],
|
||||
);
|
||||
|
||||
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
|
||||
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
|
||||
|
||||
|
|
@ -121,6 +151,14 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
|
|||
{model && (
|
||||
<Usage model={model} performance={performance} provider={provider!} usage={usage} />
|
||||
)}
|
||||
{reactions.length > 0 && (
|
||||
<ReactionDisplay
|
||||
isActive={isReactionActive}
|
||||
messageId={id}
|
||||
onReactionClick={handleReactionClick}
|
||||
reactions={reactions}
|
||||
/>
|
||||
)}
|
||||
</ChatItem>
|
||||
);
|
||||
}, isEqual);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
'use client';
|
||||
|
||||
import type { EmojiReaction } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import ReactionPicker from './ReactionPicker';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
active: css`
|
||||
background: ${token.colorFillTertiary};
|
||||
`,
|
||||
container: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
`,
|
||||
count: css`
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
reactionTag: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
height: 28px;
|
||||
padding-block: 0;
|
||||
padding-inline: 10px;
|
||||
border-radius: 14px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
|
||||
background: ${token.colorFillSecondary};
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ReactionDisplayProps {
|
||||
/**
|
||||
* Whether the current user has reacted (used for single-user mode)
|
||||
*/
|
||||
isActive?: (emoji: string) => boolean;
|
||||
/**
|
||||
* The message ID for adding reactions via the inline picker
|
||||
*/
|
||||
messageId?: string;
|
||||
/**
|
||||
* Callback when a reaction is clicked
|
||||
*/
|
||||
onReactionClick?: (emoji: string) => void;
|
||||
/**
|
||||
* The reactions to display
|
||||
*/
|
||||
reactions: EmojiReaction[];
|
||||
}
|
||||
|
||||
const ReactionDisplay = memo<ReactionDisplayProps>(
|
||||
({ reactions, onReactionClick, messageId, isActive }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
|
||||
if (reactions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} className={styles.container} horizontal>
|
||||
{reactions.map((reaction) => (
|
||||
<div
|
||||
className={cx(styles.reactionTag, isActive?.(reaction.emoji) && styles.active)}
|
||||
key={reaction.emoji}
|
||||
onClick={() => onReactionClick?.(reaction.emoji)}
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
{reaction.count > 1 && <span className={styles.count}>{reaction.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
{messageId && <ReactionPicker messageId={messageId} />}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ReactionDisplay.displayName = 'ReactionDisplay';
|
||||
|
||||
export default ReactionDisplay;
|
||||
135
src/features/Conversation/components/Reaction/ReactionPicker.tsx
Normal file
135
src/features/Conversation/components/Reaction/ReactionPicker.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
'use client';
|
||||
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { ActionIcon, Flexbox, Tooltip } from '@lobehub/ui';
|
||||
import { Popover } from 'antd';
|
||||
import { createStyles, useTheme } from 'antd-style';
|
||||
import { PlusIcon, SmilePlus } from 'lucide-react';
|
||||
import { type FC, type ReactNode, memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { globalGeneralSelectors } from '@/store/global/selectors';
|
||||
|
||||
import { useConversationStore } from '../../store';
|
||||
|
||||
const QUICK_REACTIONS = ['👍', '👎', '❤️', '😄', '😂', '😅', '🎉', '😢', '🤔', '🚀'];
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
emojiButton: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: ${token.borderRadius}px;
|
||||
|
||||
font-size: 18px;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
background: ${token.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
moreButton: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: ${token.borderRadius}px;
|
||||
|
||||
color: ${token.colorTextTertiary};
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: ${token.colorText};
|
||||
background: ${token.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
pickerContainer: css`
|
||||
padding: 4px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ReactionPickerProps {
|
||||
messageId: string;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
const ReactionPicker: FC<ReactionPickerProps> = memo(({ messageId, trigger }) => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation('chat');
|
||||
const theme = useTheme();
|
||||
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
|
||||
const addReaction = useConversationStore((s) => s.addReaction);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showFullPicker, setShowFullPicker] = useState(false);
|
||||
|
||||
const handleSelect = (emoji: string) => {
|
||||
addReaction(messageId, emoji);
|
||||
setOpen(false);
|
||||
setShowFullPicker(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (visible: boolean) => {
|
||||
setOpen(visible);
|
||||
if (!visible) setShowFullPicker(false);
|
||||
};
|
||||
|
||||
const content = showFullPicker ? (
|
||||
<Picker
|
||||
data={data}
|
||||
locale={locale?.split('-')[0] || 'en'}
|
||||
onEmojiSelect={(emoji: any) => handleSelect(emoji.native)}
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
theme={theme.appearance === 'dark' ? 'dark' : 'light'}
|
||||
/>
|
||||
) : (
|
||||
<Flexbox className={styles.pickerContainer} gap={4} horizontal wrap="wrap">
|
||||
{QUICK_REACTIONS.map((emoji) => (
|
||||
<div className={styles.emojiButton} key={emoji} onClick={() => handleSelect(emoji)}>
|
||||
{emoji}
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.moreButton} onClick={() => setShowFullPicker(true)}>
|
||||
<PlusIcon size={16} />
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={content}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open}
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
placement="top"
|
||||
trigger="click"
|
||||
>
|
||||
{trigger || (
|
||||
<span {...(open ? { 'data-popup-open': '' } : {})}>
|
||||
<Tooltip title={t('messageAction.reaction')}>
|
||||
<ActionIcon icon={SmilePlus} size="small" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
ReactionPicker.displayName = 'ReactionPicker';
|
||||
|
||||
export default ReactionPicker;
|
||||
2
src/features/Conversation/components/Reaction/index.ts
Normal file
2
src/features/Conversation/components/Reaction/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ReactionDisplay } from './ReactionDisplay';
|
||||
export { default as ReactionPicker } from './ReactionPicker';
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type ChatToolPayloadWithResult,
|
||||
type ChatVideoItem,
|
||||
type CreateMessageParams,
|
||||
|
||||
type GroundingSearch,
|
||||
type MessageMetadata,
|
||||
type MessagePluginItem,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand';
|
|||
|
||||
import type { Store as ConversationStore } from '../../../action';
|
||||
import { type MessageCRUDAction, messageCRUDSlice } from './crud';
|
||||
import { type MessageReactionAction, messageReactionSlice } from './reaction';
|
||||
import { sendMessage } from './sendMessage';
|
||||
import { type MessageStateAction, messageStateSlice } from './state';
|
||||
|
||||
|
|
@ -10,10 +11,11 @@ import { type MessageStateAction, messageStateSlice } from './state';
|
|||
*
|
||||
* Handles all message operations:
|
||||
* - CRUD (create, read, update, delete)
|
||||
* - Reaction (add, remove emoji reactions)
|
||||
* - State management (loading, collapsed, editing)
|
||||
* - Sending messages
|
||||
*/
|
||||
export interface MessageAction extends MessageCRUDAction, MessageStateAction {
|
||||
export interface MessageAction extends MessageCRUDAction, MessageReactionAction, MessageStateAction {
|
||||
/**
|
||||
* Add an AI message (convenience method)
|
||||
*/
|
||||
|
|
@ -39,6 +41,9 @@ export const messageSlice: StateCreator<
|
|||
// Spread CRUD actions
|
||||
...messageCRUDSlice(set, get, ...rest),
|
||||
|
||||
// Spread reaction actions
|
||||
...messageReactionSlice(set, get, ...rest),
|
||||
|
||||
// Spread state actions
|
||||
...messageStateSlice(set, get, ...rest),
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import type { EmojiReaction } from '@lobechat/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import type { Store as ConversationStore } from '../../../action';
|
||||
import { dataSelectors } from '../../data/selectors';
|
||||
|
||||
export interface MessageReactionAction {
|
||||
/**
|
||||
* Add an emoji reaction to a message
|
||||
*/
|
||||
addReaction: (messageId: string, emoji: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove an emoji reaction from a message
|
||||
*/
|
||||
removeReaction: (messageId: string, emoji: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const messageReactionSlice: StateCreator<
|
||||
ConversationStore,
|
||||
[['zustand/devtools', never]],
|
||||
[],
|
||||
MessageReactionAction
|
||||
> = (set, get) => ({
|
||||
addReaction: async (messageId, emoji) => {
|
||||
const { updateMessageMetadata } = get();
|
||||
const message = dataSelectors.getDisplayMessageById(messageId)(get());
|
||||
const userId = userProfileSelectors.userId(useUserStore.getState())!;
|
||||
|
||||
const currentReactions = message?.metadata?.reactions || [];
|
||||
const existingIndex = currentReactions.findIndex((r: EmojiReaction) => r.emoji === emoji);
|
||||
|
||||
let newReactions: EmojiReaction[];
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
newReactions = currentReactions.map((r: EmojiReaction, i: number) =>
|
||||
i === existingIndex ? { ...r, count: r.count + 1, users: [...r.users, userId] } : r,
|
||||
);
|
||||
} else {
|
||||
newReactions = [...currentReactions, { count: 1, emoji, users: [userId] }];
|
||||
}
|
||||
|
||||
await updateMessageMetadata(messageId, { reactions: newReactions });
|
||||
},
|
||||
|
||||
removeReaction: async (messageId, emoji) => {
|
||||
const { updateMessageMetadata } = get();
|
||||
const message = dataSelectors.getDisplayMessageById(messageId)(get());
|
||||
const userId = userProfileSelectors.userId(useUserStore.getState())!;
|
||||
|
||||
const currentReactions = message?.metadata?.reactions || [];
|
||||
const existingIndex = currentReactions.findIndex((r: EmojiReaction) => r.emoji === emoji);
|
||||
|
||||
if (existingIndex < 0) return;
|
||||
|
||||
const emojiReaction = currentReactions[existingIndex];
|
||||
let newReactions: EmojiReaction[];
|
||||
|
||||
if (emojiReaction.count <= 1) {
|
||||
newReactions = currentReactions.filter((_: EmojiReaction, i: number) => i !== existingIndex);
|
||||
} else {
|
||||
const userIndex = emojiReaction.users.lastIndexOf(userId);
|
||||
const newUsers =
|
||||
userIndex >= 0
|
||||
? [
|
||||
...emojiReaction.users.slice(0, userIndex),
|
||||
...emojiReaction.users.slice(userIndex + 1),
|
||||
]
|
||||
: emojiReaction.users.slice(0, -1);
|
||||
newReactions = currentReactions.map((r: EmojiReaction, i: number) =>
|
||||
i === existingIndex ? { ...r, count: r.count - 1, users: newUsers } : r,
|
||||
);
|
||||
}
|
||||
|
||||
await updateMessageMetadata(messageId, { reactions: newReactions });
|
||||
},
|
||||
});
|
||||
|
|
@ -190,6 +190,7 @@ export default {
|
|||
'messageAction.delAndRegenerate': 'Delete and Regenerate',
|
||||
'messageAction.deleteDisabledByThreads': 'This message has a subtopic and can’t be deleted',
|
||||
'messageAction.expand': 'Expand Message',
|
||||
'messageAction.reaction': 'Add Reaction',
|
||||
'messageAction.regenerate': 'Regenerate',
|
||||
'messages.dm.sentTo': 'Visible only to {{name}}',
|
||||
'messages.dm.title': 'DM',
|
||||
|
|
|
|||
Loading…
Reference in a new issue