Add mentions, pin messages and conversations, and search within chats

This commit is contained in:
2026-09-30 10:40:29 +03:30
parent 7c4b24fad6
commit f71e78f3a7
9 changed files with 1152 additions and 31 deletions
+128 -19
View File
@@ -33,7 +33,49 @@ export type RoomLiveEvent = {
fromSelf: boolean;
};
const PAGE_SIZE = 50;
export type MessagePinEvent = {
roomId: string;
/** null = unpinned */
messageId: string | null;
preview: string;
fromSelf: boolean;
};
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
export function onRoomLiveEvent(listener: RoomListener) {
roomListeners.add(listener);
return () => roomListeners.delete(listener);
}
function emitRoomLive(event: RoomLiveEvent) {
for (const listener of roomListeners) {
try {
listener(event);
} catch {
// ignore listener errors
}
}
}
type PinListener = (event: MessagePinEvent) => void;
const pinListeners = new Set<PinListener>();
export function onMessagePinEvent(listener: PinListener) {
pinListeners.add(listener);
return () => pinListeners.delete(listener);
}
function emitMessagePin(event: MessagePinEvent) {
for (const listener of pinListeners) {
try {
listener(event);
} catch {
// ignore
}
}
}
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
@@ -41,6 +83,9 @@ const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
const HARD_DELETE_MARKER = '__MENTO_DEL__';
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
/** Realtime pin / unpin control messages. */
const PIN_CMD_PREFIX = '__MENTO_PIN__:';
const UNPIN_CMD = '__MENTO_UNPIN__';
function isDeletedContent(content: string): boolean {
const t = content.trim();
@@ -48,7 +93,9 @@ function isDeletedContent(content: string): boolean {
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
t.includes('MENTO_DEL') ||
t === HARD_DELETE_MARKER ||
t.startsWith(HIDE_CMD_PREFIX)
t.startsWith(HIDE_CMD_PREFIX) ||
t.startsWith(PIN_CMD_PREFIX) ||
t === UNPIN_CMD
);
}
@@ -59,6 +106,15 @@ function parseHideCommand(content: string): string | null {
return id || null;
}
/** null = not a pin cmd; '' = unpin; otherwise messageId */
function parsePinCommand(content: string): string | null {
const t = content.trim();
if (t === UNPIN_CMD) return '';
if (!t.startsWith(PIN_CMD_PREFIX)) return null;
const id = t.slice(PIN_CMD_PREFIX.length).trim();
return id || null;
}
function purgeMessageById(id: string) {
const key = String(id || '').trim();
if (!key) return;
@@ -159,23 +215,7 @@ const activeRoomId = ref('');
const unreadTotal = ref(0);
const pageOpen = ref(false);
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
export function onRoomLiveEvent(listener: RoomListener) {
roomListeners.add(listener);
return () => roomListeners.delete(listener);
}
function emitRoomLive(event: RoomLiveEvent) {
for (const listener of roomListeners) {
try {
listener(event);
} catch {
// ignore listener errors
}
}
}
const PAGE_SIZE = 200;
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
@@ -734,6 +774,27 @@ function onSocketMessage(event: MessageEvent) {
void persistHiddenOnServer(roomId, ids, true);
return;
}
const pinTarget = parsePinCommand(purgeContent);
if (pinTarget !== null) {
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]);
if (pinTarget === '') {
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: false });
} else {
const found = messages.value.find((m) => m.id === pinTarget);
emitMessagePin({
roomId,
messageId: pinTarget,
preview: found ? previewLabelForMessage(found) : '',
fromSelf: false,
});
}
return;
}
if (purgeId && isDeletedContent(purgeContent)) {
purgeMessageById(purgeId);
return;
@@ -1271,6 +1332,52 @@ export function useOrgChat() {
}
}
async function pinChatMessage(roomId: string, msg: ChatMessage) {
const messageId = String(msg.id || '').trim();
if (!roomId || !messageId || msg.optimistic) return;
const preview = previewLabelForMessage(msg).slice(0, 240);
await api.post('/conversations/messages/pin', {
externalRoomId: roomId,
messageId,
preview,
});
emitMessagePin({ roomId, messageId, preview, fromSelf: true });
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: `${PIN_CMD_PREFIX}${messageId}`,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
async function unpinChatMessage(roomId: string) {
if (!roomId) return;
await api.post('/conversations/messages/unpin', { externalRoomId: roomId });
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: true });
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: UNPIN_CMD,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
function clearActiveRoom() {
stopHiddenPoll();
activeRoomId.value = '';
@@ -1298,6 +1405,8 @@ export function useOrgChat() {
deleteMessage,
deleteMessages,
forwardMessages,
pinChatMessage,
unpinChatMessage,
sendRead,
ensureConnected,
setConversationsPageOpen,