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)) { 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..ae730326421 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -3,9 +3,162 @@ * 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 + */ +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 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 @@ -17,19 +170,144 @@ 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) +} + +/** + * 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: { + 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: { + 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: { + 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: + case MESSAGE.SYSTEM_TYPE.GUEST_MODERATOR_PROMOTED: { + 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: { + 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: + 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: { + 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: { + return t('spreed', 'Someone voted on the poll {poll}') + } + case MESSAGE.SYSTEM_TYPE.POLL_CLOSED: { + 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: { + 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: { + 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 + throw new Error() + } + } }