Edit text and links, mute and unmute chat

This commit is contained in:
2026-09-30 14:58:44 +03:30
parent d3986da28b
commit 75b83b7f1b
9 changed files with 1070 additions and 51 deletions
+143 -2
View File
@@ -31,6 +31,8 @@ export type RoomLiveEvent = {
preview: string;
at: string;
fromSelf: boolean;
/** متن خام برای تشخیص منشن در mute هوشمند */
content?: string;
};
export type MessagePinEvent = {
@@ -41,6 +43,21 @@ export type MessagePinEvent = {
fromSelf: boolean;
};
export type MessageReactionEvent = {
roomId: string;
messageId: string;
emoji: string;
added: boolean;
fromSelf: boolean;
};
export type MessageReactionAgg = {
messageId: string;
emoji: string;
count: number;
me: boolean;
};
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
@@ -76,6 +93,47 @@ function emitMessagePin(event: MessagePinEvent) {
}
}
}
type ReactionListener = (event: MessageReactionEvent) => void;
const reactionListeners = new Set<ReactionListener>();
export function onMessageReactionEvent(listener: ReactionListener) {
reactionListeners.add(listener);
return () => reactionListeners.delete(listener);
}
function emitMessageReaction(event: MessageReactionEvent) {
for (const listener of reactionListeners) {
try {
listener(event);
} catch {
// ignore
}
}
}
/** mute map for unread suppression (filled by ConversationsView) */
const mutedRoomPrefs = ref<Record<string, { allowMentions: boolean }>>({});
const mentionSelfName = ref('');
export function setChatMutePrefs(
prefs: Record<string, { allowMentions: boolean }>,
selfFullName = '',
) {
mutedRoomPrefs.value = prefs;
mentionSelfName.value = selfFullName.trim();
}
function shouldCountUnread(roomId: string, content: string, fromSelf: boolean): boolean {
if (fromSelf) return false;
const mute = mutedRoomPrefs.value[roomId];
if (!mute) return true;
if (mute.allowMentions && mentionSelfName.value) {
const needle = `@${mentionSelfName.value}`;
if (content.includes(needle)) return true;
}
return false;
}
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
@@ -88,6 +146,8 @@ const PIN_CMD_PREFIX = '__MENTO_PIN__:';
const UNPIN_CMD = '__MENTO_UNPIN__';
/** Typing fallback when WS `typing` event is not relayed by external chat. */
const TYPING_CMD_PREFIX = '__MENTO_TYPING__:';
/** Reaction sync: __MENTO_REACT__:messageId:emoji:1|0 */
const REACT_CMD_PREFIX = '__MENTO_REACT__:';
function isDeletedContent(content: string): boolean {
const t = content.trim();
@@ -98,7 +158,8 @@ function isDeletedContent(content: string): boolean {
t.startsWith(HIDE_CMD_PREFIX) ||
t.startsWith(PIN_CMD_PREFIX) ||
t === UNPIN_CMD ||
t.startsWith(TYPING_CMD_PREFIX)
t.startsWith(TYPING_CMD_PREFIX) ||
t.startsWith(REACT_CMD_PREFIX)
);
}
@@ -118,6 +179,21 @@ function parsePinCommand(content: string): string | null {
return id || null;
}
/** null = not react; otherwise { messageId, emoji, added } */
function parseReactCommand(
content: string,
): { messageId: string; emoji: string; added: boolean } | null {
const t = content.trim();
if (!t.startsWith(REACT_CMD_PREFIX)) return null;
const parts = t.slice(REACT_CMD_PREFIX.length).split(':');
if (parts.length < 3) return null;
const messageId = parts[0]?.trim();
const emoji = parts[1]?.trim();
const flag = parts[2]?.trim();
if (!messageId || !emoji) return null;
return { messageId, emoji, added: flag !== '0' };
}
function purgeMessageById(id: string) {
const key = String(id || '').trim();
if (!key) return;
@@ -772,7 +848,9 @@ function handleIncomingMessage(msg: ChatMessage) {
sendRead(msg.roomId);
}
} else if (!fromSelf) {
unreadTotal.value += 1;
if (shouldCountUnread(msg.roomId, msg.content || '', fromSelf)) {
unreadTotal.value += 1;
}
}
emitRoomLive({
@@ -780,6 +858,7 @@ function handleIncomingMessage(msg: ChatMessage) {
preview: previewLabelForMessage(msg),
at: msg.createdAt,
fromSelf,
content: msg.type === 'text' ? msg.content : '',
});
}
@@ -955,6 +1034,26 @@ function onSocketMessage(event: MessageEvent) {
}
return;
}
const reactCmd = parseReactCommand(purgeContent);
if (reactCmd) {
const roomId = String(
(parsed.room_id as string) ||
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
activeRoomId.value,
);
if (purgeId) {
rememberHidden(roomId, [purgeId]);
void persistHiddenOnServer(roomId, [purgeId], true);
}
emitMessageReaction({
roomId,
messageId: reactCmd.messageId,
emoji: reactCmd.emoji,
added: reactCmd.added,
fromSelf: false,
});
return;
}
if (purgeId && isDeletedContent(purgeContent)) {
purgeMessageById(purgeId);
return;
@@ -1626,6 +1725,47 @@ export function useOrgChat() {
}
}
async function toggleMessageReaction(roomId: string, messageId: string, emoji: string) {
const rid = String(roomId || '').trim();
const mid = String(messageId || '').trim();
const em = String(emoji || '').trim().slice(0, 16);
if (!rid || !mid || !em) return null;
const { data } = await api.post<{
added: boolean;
reactions: MessageReactionAgg[];
}>('/conversations/messages/reactions', {
externalRoomId: rid,
messageId: mid,
emoji: em,
});
emitMessageReaction({
roomId: rid,
messageId: mid,
emoji: em,
added: Boolean(data?.added),
fromSelf: true,
});
try {
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(rid, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: rid,
content: `${REACT_CMD_PREFIX}${mid}:${em}:${data?.added ? '1' : '0'}`,
}),
);
void persistHiddenOnServer(rid, [controlId], true);
}
} catch {
// ignore
}
return Array.isArray(data?.reactions) ? data.reactions : null;
}
function clearActiveRoom() {
stopHiddenPoll();
activeRoomId.value = '';
@@ -1662,6 +1802,7 @@ export function useOrgChat() {
forwardMessages,
pinChatMessage,
unpinChatMessage,
toggleMessageReaction,
sendRead,
ensureConnected,
setConversationsPageOpen,