From 827b4be6173d4969a85ff16999d6a52cc519b75e Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 22 Dec 2025 12:17:16 +0100 Subject: [PATCH 1/8] refactor: use constants in isHiddenSystemMessage Signed-off-by: Maksim Sukharev --- src/constants.ts | 2 ++ src/utils/message.ts | 27 ++++++++++++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 88356d2a5c3..0b9b408a892 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -169,6 +169,8 @@ export const ATTENDEE = { BOT_PREFIX: 'bot-', BRIDGE_BOT_ID: 'bridge-bot', + ACTOR_CLI_ID: 'cli', + ACTOR_SYSTEM_ID: 'system', CHANGELOG_BOT_ID: 'changelog', SAMPLE_BOT_ID: 'sample', } as const diff --git a/src/utils/message.ts b/src/utils/message.ts index 8c130d97dd7..7bac8b57452 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -7,6 +7,20 @@ import type { ChatMessage } from '../types/index.ts' import { ATTENDEE, MESSAGE } from '../constants.ts' +/** + * System messages that aren't shown separately in the chat + */ +const SYSTEM_MESSAGE_TYPE_HIDDEN = [ + MESSAGE.SYSTEM_TYPE.REACTION, + MESSAGE.SYSTEM_TYPE.REACTION_DELETED, + MESSAGE.SYSTEM_TYPE.REACTION_REVOKED, + MESSAGE.SYSTEM_TYPE.POLL_VOTED, + MESSAGE.SYSTEM_TYPE.MESSAGE_DELETED, + MESSAGE.SYSTEM_TYPE.MESSAGE_EDITED, + MESSAGE.SYSTEM_TYPE.THREAD_CREATED, + MESSAGE.SYSTEM_TYPE.THREAD_RENAMED, +] as const + /** * Returns whether the given system message should be hidden in the UI * @@ -17,19 +31,10 @@ export function isHiddenSystemMessage(message: ChatMessage): boolean { // System message for auto unpin if (message.systemMessage === MESSAGE.SYSTEM_TYPE.MESSAGE_UNPINNED && message.actorType === ATTENDEE.ACTOR_TYPE.GUESTS - && message.actorId === 'system' + && message.actorId === ATTENDEE.ACTOR_SYSTEM_ID ) { return true } - return [ - MESSAGE.SYSTEM_TYPE.REACTION, - MESSAGE.SYSTEM_TYPE.REACTION_DELETED, - MESSAGE.SYSTEM_TYPE.REACTION_REVOKED, - MESSAGE.SYSTEM_TYPE.POLL_VOTED, - MESSAGE.SYSTEM_TYPE.MESSAGE_DELETED, - MESSAGE.SYSTEM_TYPE.MESSAGE_EDITED, - MESSAGE.SYSTEM_TYPE.THREAD_CREATED, - MESSAGE.SYSTEM_TYPE.THREAD_RENAMED, - ].includes(message.systemMessage) + return SYSTEM_MESSAGE_TYPE_HIDDEN.includes(message.systemMessage) } From 8fe9537b1036005afac8e31c1dd787391d639ef6 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 22 Dec 2025 12:27:47 +0100 Subject: [PATCH 2/8] wip: prepare help functions and constants for localization Signed-off-by: Maksim Sukharev --- src/utils/message.ts | 221 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 2 deletions(-) diff --git a/src/utils/message.ts b/src/utils/message.ts index 7bac8b57452..d5db8f6faf4 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -3,9 +3,72 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ChatMessage } from '../types/index.ts' +import type { ChatMessage, Conversation } from '../types/index.ts' -import { ATTENDEE, MESSAGE } from '../constants.ts' +import { t } from '@nextcloud/l10n' +import { ATTENDEE, CONVERSATION, MENTION, MESSAGE } from '../constants.ts' + +/** + * Module augmentation to declare the t function from `@nextcloud/l10n` + * (no placeholders should be replaced until it reaches NcRichText) + * + * @param app - app name ('spreed') + * @param text - translated string + */ +declare module '@nextcloud/l10n' { + export function t(app: string, text: string): string +} + +/** + * Returns correct mention type for self actor + */ +const SELF_MENTION_TYPE = { + [ATTENDEE.ACTOR_TYPE.USERS]: MENTION.TYPE.USER, + [ATTENDEE.ACTOR_TYPE.FEDERATED_USERS]: MENTION.TYPE.FEDERATED_USER, + [ATTENDEE.ACTOR_TYPE.EMAILS]: MENTION.TYPE.EMAIL, + [ATTENDEE.ACTOR_TYPE.GUESTS]: MENTION.TYPE.GUEST, +} as const + +type SelfMentionTypeKey = keyof typeof SELF_MENTION_TYPE + +/** + * Sync with server-side constant SYSTEM_MESSAGE_TYPE_RELAY in lib/Signaling/Listener.php + */ +const SYSTEM_MESSAGE_TYPE_RELAY = [ + MESSAGE.SYSTEM_TYPE.CALL_STARTED, // 'call_started', + MESSAGE.SYSTEM_TYPE.CALL_JOINED, // 'call_joined', + MESSAGE.SYSTEM_TYPE.CALL_LEFT, // 'call_left', + MESSAGE.SYSTEM_TYPE.CALL_ENDED, // 'call_ended', + MESSAGE.SYSTEM_TYPE.CALL_ENDED_EVERYONE, // 'call_ended_everyone', + MESSAGE.SYSTEM_TYPE.THREAD_CREATED, // 'thread_created', + MESSAGE.SYSTEM_TYPE.THREAD_RENAMED, // 'thread_renamed', + MESSAGE.SYSTEM_TYPE.MESSAGE_DELETED, // 'message_deleted', + MESSAGE.SYSTEM_TYPE.MESSAGE_EDITED, // 'message_edited', + MESSAGE.SYSTEM_TYPE.MODERATOR_PROMOTED, // 'moderator_promoted', + MESSAGE.SYSTEM_TYPE.MODERATOR_DEMOTED, // 'moderator_demoted', + MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_PROMOTED, // 'guest_moderator_promoted', + MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_DEMOTED, // 'guest_moderator_demoted', + MESSAGE.SYSTEM_TYPE.FILE_SHARED, // 'file_shared', + MESSAGE.SYSTEM_TYPE.OBJECT_SHARED, // 'object_shared', + MESSAGE.SYSTEM_TYPE.HISTORY_CLEARED, // 'history_cleared', + MESSAGE.SYSTEM_TYPE.POLL_VOTED, // 'poll_voted', + MESSAGE.SYSTEM_TYPE.POLL_CLOSED, // 'poll_closed', + MESSAGE.SYSTEM_TYPE.RECORDING_STARTED, // 'recording_started', + MESSAGE.SYSTEM_TYPE.RECORDING_STOPPED, // 'recording_stopped', +] as const + +/** + * System messages that aren't necessary to translate + */ +const SYSTEM_MESSAGE_TYPE_UNTRANSLATED = [ + MESSAGE.SYSTEM_TYPE.REACTION, + MESSAGE.SYSTEM_TYPE.REACTION_DELETED, + MESSAGE.SYSTEM_TYPE.REACTION_REVOKED, + MESSAGE.SYSTEM_TYPE.MESSAGE_DELETED, + MESSAGE.SYSTEM_TYPE.MESSAGE_EDITED, + MESSAGE.SYSTEM_TYPE.THREAD_CREATED, + MESSAGE.SYSTEM_TYPE.THREAD_RENAMED, +] as const /** * System messages that aren't shown separately in the chat @@ -21,6 +84,82 @@ const SYSTEM_MESSAGE_TYPE_HIDDEN = [ MESSAGE.SYSTEM_TYPE.THREAD_RENAMED, ] as const +/** + * Returns whether the actor is CLI (Administrator) + * + * @param message Chat message + */ +function cliIsActor(message: ChatMessage) { + return message.messageParameters.actor.id === ATTENDEE.ACTOR_CLI_ID + && message.messageParameters.actor.type === MENTION.TYPE.GUEST +} + +/** + * Returns whether the conversation is one-to-one + * + * @param type conversation type + */ +function conversationIsOneToOne(type: number) { + return type === CONVERSATION.TYPE.ONE_TO_ONE + || type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER +} + +/** + * Returns whether the call is started silently + * FIXME should be coming with the message metadata from HPB + * + * @param message conversation type + */ +function callIsSilent(message: ChatMessage) { + return /* $metaData[Message::METADATA_SILENT] */false +} + +/** + * Returns whether the actor is current user (You) + * + * @param actorId user id + * @param actorType user type + */ +function selfMentionId(actorId: string, actorType: string) { + if (actorType === ATTENDEE.ACTOR_TYPE.GUESTS) { + return `${MENTION.TYPE.GUEST}/${actorId}` + } + return actorId +} + +/** + * Returns whether the actor is current user (You) + * + * @param actorType user type + */ +function selfMentionType(actorType: string) { + return SELF_MENTION_TYPE[actorType as SelfMentionTypeKey] ?? MENTION.TYPE.GUEST +} + +/** + * Returns whether the actor is current user (You) + * + * @param message Chat message + * @param selfActorId Current user id + * @param selfActorType Current user type + */ +function selfIsActor(message: ChatMessage, selfActorId: string, selfActorType: string) { + return message.messageParameters.actor.id === selfActorId + && message.messageParameters.actor.type === selfMentionType(selfActorType) +} + +/** + * Returns whether the actor is current user (you) + * + * @param message Chat message + * @param selfActorId Current user id + * @param selfActorType Current user type + */ +function selfIsUser(message: ChatMessage, selfActorId: string, selfActorType: string) { + return message.messageParameters.user.id === selfMentionId(selfActorId, selfActorType) + && message.messageParameters.user.type === selfMentionType(selfActorType) +} + /** * Returns whether the given system message should be hidden in the UI * @@ -38,3 +177,81 @@ export function isHiddenSystemMessage(message: ChatMessage): boolean { return SYSTEM_MESSAGE_TYPE_HIDDEN.includes(message.systemMessage) } + +/** + * Returns whether the given system message should be hidden in the UI + * + * @param payload Chat message + * @param payload.message Chat message + * @param payload.conversation Current conversation + * @return whether the message is hidden in the UI + */ +export function tryLocalizeSystemMessage({ + message, + conversation, +}: { message: ChatMessage, conversation: Conversation }): string { + if (SYSTEM_MESSAGE_TYPE_UNTRANSLATED.includes(message.systemMessage)) { + // Don't localize hidden system messages, keep original + return message.message + } + + if (!SYSTEM_MESSAGE_TYPE_RELAY.includes(message.systemMessage)) { + // Don't localize non-supported relayed system messages, do polling + throw new Error() + } + + switch (message.systemMessage) { + case MESSAGE.SYSTEM_TYPE.CALL_STARTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.CALL_JOINED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.CALL_LEFT: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.CALL_ENDED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.CALL_ENDED_EVERYONE: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.MODERATOR_PROMOTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.MODERATOR_DEMOTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_PROMOTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_DEMOTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.FILE_SHARED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.OBJECT_SHARED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.HISTORY_CLEARED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.POLL_VOTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.POLL_CLOSED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.RECORDING_STARTED: { + throw new Error() + } + case MESSAGE.SYSTEM_TYPE.RECORDING_STOPPED: { + throw new Error() + } + default: { + // Don't localize non-supported relayed system messages, do polling + throw new Error() + } + } +} From fd463e58c39f6689005e1342c66d3df36cb02781 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 22 Dec 2025 18:25:48 +0100 Subject: [PATCH 3/8] wip: translate 'call' system messages Signed-off-by: Maksim Sukharev --- src/utils/message.ts | 46 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/utils/message.ts b/src/utils/message.ts index d5db8f6faf4..34480b7bbd0 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -202,18 +202,52 @@ export function tryLocalizeSystemMessage({ switch (message.systemMessage) { case MESSAGE.SYSTEM_TYPE.CALL_STARTED: { - throw new Error() + if (callIsSilent(message)) { + if (selfIsActor(message, conversation.actorId, conversation.actorType)) { + return conversationIsOneToOne(conversation.type) + ? t('spreed', 'Outgoing silent call') + : t('spreed', 'You started a silent call') + } else { + return conversationIsOneToOne(conversation.type) + ? t('spreed', 'Incoming silent call') + : t('spreed', '{actor} started a silent call') + } + } else { + if (selfIsActor(message, conversation.actorId, conversation.actorType)) { + return conversationIsOneToOne(conversation.type) + ? t('spreed', 'Outgoing call') + : t('spreed', 'You started a call') + } else { + return conversationIsOneToOne(conversation.type) + ? t('spreed', 'Incoming call') + : t('spreed', '{actor} started a call') + } + } } case MESSAGE.SYSTEM_TYPE.CALL_JOINED: { - throw new Error() + return selfIsActor(message, conversation.actorId, conversation.actorType) + ? t('spreed', 'You joined the call') + : t('spreed', '{actor} joined the call') } case MESSAGE.SYSTEM_TYPE.CALL_LEFT: { - throw new Error() - } - case MESSAGE.SYSTEM_TYPE.CALL_ENDED: { - throw new Error() + return selfIsActor(message, conversation.actorId, conversation.actorType) + ? t('spreed', 'You left the call') + : t('spreed', '{actor} left the call') } + case MESSAGE.SYSTEM_TYPE.CALL_ENDED: case MESSAGE.SYSTEM_TYPE.CALL_ENDED_EVERYONE: { + // TODO discuss if worth localizing at all or only basic cases + // #parseCall - 150 lines of PHP code + // requires to know amount of guests, duration, $maxDurationWasReached + // Alternatives: parse message.message directly for this information + // Simpler alternatives: just return t('spreed', message.message) + + // Can be simplified to this: + // if ($message === 'call_ended') { + // $subject = $this->l->t('Call ended (Duration {duration})'); + // } else { + // $subject = $this->l->t('{actor} ended the call (Duration {duration})'); + // } throw new Error() } case MESSAGE.SYSTEM_TYPE.MODERATOR_PROMOTED: { From d18413ee2ea3f4fe28c65a3f7d1da6a12442057f Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 22 Dec 2025 12:59:24 +0100 Subject: [PATCH 4/8] wip: translate 'moderator' system messages Signed-off-by: Maksim Sukharev --- src/utils/message.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/utils/message.ts b/src/utils/message.ts index 34480b7bbd0..821f0bf2509 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -250,17 +250,31 @@ export function tryLocalizeSystemMessage({ // } throw new Error() } - case MESSAGE.SYSTEM_TYPE.MODERATOR_PROMOTED: { - throw new Error() - } - case MESSAGE.SYSTEM_TYPE.MODERATOR_DEMOTED: { - throw new Error() - } + case MESSAGE.SYSTEM_TYPE.MODERATOR_PROMOTED: case MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_PROMOTED: { - throw new Error() + if (selfIsActor(message, conversation.actorId, conversation.actorType)) { + return t('spreed', 'You promoted {user} to moderator') + } else if (selfIsUser(message, conversation.actorId, conversation.actorType)) { + return cliIsActor(message) + ? t('spreed', 'An administrator promoted you to moderator') + : t('spreed', '{actor} promoted you to moderator') + } + return cliIsActor(message) + ? t('spreed', 'An administrator promoted {user} to moderator') + : t('spreed', '{actor} promoted {user} to moderator') } + case MESSAGE.SYSTEM_TYPE.MODERATOR_DEMOTED: case MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_DEMOTED: { - throw new Error() + if (selfIsActor(message, conversation.actorId, conversation.actorType)) { + return t('spreed', 'You demoted {user} from moderator') + } else if (selfIsUser(message, conversation.actorId, conversation.actorType)) { + return cliIsActor(message) + ? t('spreed', 'An administrator demoted you from moderator') + : t('spreed', '{actor} demoted you from moderator') + } + return cliIsActor(message) + ? t('spreed', 'An administrator demoted {user} from moderator') + : t('spreed', '{actor} demoted {user} from moderator') } case MESSAGE.SYSTEM_TYPE.FILE_SHARED: { throw new Error() From 7b2804cab5591f96470eebe7ff5599f609518686 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 23 Dec 2025 12:36:08 +0100 Subject: [PATCH 5/8] wip: translate 'history cleared' system messages Signed-off-by: Maksim Sukharev --- src/utils/message.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/message.ts b/src/utils/message.ts index 821f0bf2509..37222df3cc3 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -283,7 +283,9 @@ export function tryLocalizeSystemMessage({ throw new Error() } case MESSAGE.SYSTEM_TYPE.HISTORY_CLEARED: { - throw new Error() + return selfIsActor(message, conversation.actorId, conversation.actorType) + ? t('spreed', 'You cleared the history of the conversation') + : t('spreed', '{actor} cleared the history of the conversation') } case MESSAGE.SYSTEM_TYPE.POLL_VOTED: { throw new Error() From b3d4bb373ebcbf5a261bf5cafa7bc2f2b2200f6c Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 23 Dec 2025 12:59:33 +0100 Subject: [PATCH 6/8] wip: translate 'poll' system messages Signed-off-by: Maksim Sukharev --- src/utils/message.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/utils/message.ts b/src/utils/message.ts index 37222df3cc3..72401e44068 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -288,10 +288,12 @@ export function tryLocalizeSystemMessage({ : t('spreed', '{actor} cleared the history of the conversation') } case MESSAGE.SYSTEM_TYPE.POLL_VOTED: { - throw new Error() + return t('spreed', 'Someone voted on the poll {poll}') } case MESSAGE.SYSTEM_TYPE.POLL_CLOSED: { - throw new Error() + return selfIsActor(message, conversation.actorId, conversation.actorType) + ? t('spreed', 'You ended the poll {poll}') + : t('spreed', '{actor} ended the poll {poll}') } case MESSAGE.SYSTEM_TYPE.RECORDING_STARTED: { throw new Error() From e7ef0c459e99d3f25e58f190ff84acd8d1dddadd Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 23 Dec 2025 13:17:02 +0100 Subject: [PATCH 7/8] wip: translate 'recording' system messages Signed-off-by: Maksim Sukharev --- src/utils/message.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/utils/message.ts b/src/utils/message.ts index 72401e44068..ae730326421 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -276,10 +276,10 @@ export function tryLocalizeSystemMessage({ ? t('spreed', 'An administrator demoted {user} from moderator') : t('spreed', '{actor} demoted {user} from moderator') } - case MESSAGE.SYSTEM_TYPE.FILE_SHARED: { - throw new Error() - } + case MESSAGE.SYSTEM_TYPE.FILE_SHARED: case MESSAGE.SYSTEM_TYPE.OBJECT_SHARED: { + // Backend transforms both 'file_shared' and 'object_shared' to normal chat message, + // these should not be received by client throw new Error() } case MESSAGE.SYSTEM_TYPE.HISTORY_CLEARED: { @@ -296,10 +296,14 @@ export function tryLocalizeSystemMessage({ : t('spreed', '{actor} ended the poll {poll}') } case MESSAGE.SYSTEM_TYPE.RECORDING_STARTED: { - throw new Error() + return selfIsActor(message, conversation.actorId, conversation.actorType) + ? t('spreed', 'You started the video recording') + : t('spreed', '{actor} started the video recording') } case MESSAGE.SYSTEM_TYPE.RECORDING_STOPPED: { - throw new Error() + return selfIsActor(message, conversation.actorId, conversation.actorType) + ? t('spreed', 'You stopped the video recording') + : t('spreed', '{actor} stopped the video recording') } default: { // Don't localize non-supported relayed system messages, do polling From 8171f9e19ea53426f63d7af183fa0553b7cce08d Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 23 Dec 2025 11:51:20 +0100 Subject: [PATCH 8/8] fix(useGetMessages): process translation on receiving from HPB Signed-off-by: Maksim Sukharev --- src/composables/useGetMessages.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/composables/useGetMessages.ts b/src/composables/useGetMessages.ts index 3edb3606a23..c1f7ca91ae0 100644 --- a/src/composables/useGetMessages.ts +++ b/src/composables/useGetMessages.ts @@ -27,6 +27,7 @@ import { EventBus } from '../services/EventBus.ts' import { useChatStore } from '../stores/chat.ts' import { useChatExtrasStore } from '../stores/chatExtras.ts' import { debugTimer } from '../utils/debugTimer.ts' +import { tryLocalizeSystemMessage } from '../utils/message.ts' import { useGetThreadId } from './useGetThreadId.ts' import { useGetToken } from './useGetToken.ts' @@ -632,6 +633,19 @@ export function useGetMessagesProvider() { return } + if (message.systemMessage !== '' && conversation.value) { + // Attempt to localize non-system messages + try { + message.message = tryLocalizeSystemMessage({ + message, + conversation: conversation.value, + }) + } catch (exception) { + tryPollNewMessages() + return + } + } + // Patch for federated conversations: disable unsupported file shares if (conversation.value?.remoteServer && Object.keys(message.messageParameters ?? {}).some((key) => key.startsWith('file')) && [MESSAGE.TYPE.COMMENT, MESSAGE.TYPE.VOICE_MESSAGE, MESSAGE.TYPE.RECORD_VIDEO, MESSAGE.TYPE.RECORD_AUDIO].includes(message.messageType)) {