Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/composables/useGetMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
304 changes: 291 additions & 13 deletions src/utils/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
}