import { addActionHandler, getGlobal, setGlobal } from '../../index'; import type { ApiMessage } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types'; import { FocusDirection } from '../../../types'; import type { TabState, GlobalState, ActionReturnType, } from '../../types'; import { ANIMATION_END_DELAY, APP_VERSION, RELEASE_DATETIME, FAST_SMOOTH_MAX_DURATION, SERVICE_NOTIFICATIONS_USER_ID, } from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/environment'; import { enterMessageSelectMode, toggleMessageSelection, exitMessageSelectMode, replaceThreadParam, replaceTabThreadParam, updateFocusDirection, updateFocusedMessage, } from '../../reducers'; import { selectCurrentChat, selectViewportIds, selectIsRightColumnShown, selectCurrentMessageList, selectChat, selectThreadInfo, selectChatMessages, selectAllowedMessageActions, selectMessageIdsByGroupId, selectForwardedMessageIdsByGroupId, selectIsViewportNewest, selectReplyingToId, selectReplyStack, selectSender, selectChatScheduledMessages, selectTabState, } from '../../selectors'; import { compact, findLast } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; import versionNotification from '../../../versionNotification.txt'; import parseMessageInput from '../../../util/parseMessageInput'; import { getMessageSummaryText, getSenderTitle } from '../../helpers'; import * as langProvider from '../../../util/langProvider'; import { copyHtmlToClipboard } from '../../../util/clipboard'; import { renderMessageSummaryHtml } from '../../helpers/renderMessageSummaryHtml'; import { updateTabState } from '../../reducers/tabs'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getIsMobile } from '../../../hooks/useAppLayout'; const FOCUS_DURATION = 1500; const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY; const POLL_RESULT_OPEN_DELAY_MS = 450; const VERSION_NOTIFICATION_DURATION = 1000 * 60 * 60 * 24 * 3; // 3 days const SERVICE_NOTIFICATIONS_MAX_AMOUNT = 1e3; let blurTimeout: number | undefined; addActionHandler('setScrollOffset', (global, actions, payload): ActionReturnType => { const { chatId, threadId, scrollOffset, tabId = getCurrentTabId(), } = payload; global = replaceThreadParam(global, chatId, threadId, 'lastScrollOffset', scrollOffset); return replaceTabThreadParam(global, chatId, threadId, 'scrollOffset', scrollOffset, tabId); }); addActionHandler('setReplyingToId', (global, actions, payload): ActionReturnType => { const { messageId, tabId = getCurrentTabId() } = payload!; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return undefined; } const { chatId, threadId } = currentMessageList; return replaceThreadParam(global, chatId, threadId, 'replyingToId', messageId); }); addActionHandler('setEditingId', (global, actions, payload): ActionReturnType => { const { messageId, tabId = getCurrentTabId() } = payload; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return undefined; } const { chatId, threadId, type } = currentMessageList; const paramName = type === 'scheduled' ? 'editingScheduledId' : 'editingId'; return replaceThreadParam(global, chatId, threadId, paramName, messageId); }); addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType => { const { text, chatId, threadId, type, } = payload; const paramName = type === 'scheduled' ? 'editingScheduledDraft' : 'editingDraft'; return replaceThreadParam(global, chatId, threadId, paramName, text); }); addActionHandler('editLastMessage', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; if (!chatId || !threadId) { return undefined; } const chatMessages = selectChatMessages(global, chatId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId); if (!chatMessages || !viewportIds) { return undefined; } const lastOwnEditableMessageId = findLast(viewportIds, (id) => { return Boolean(chatMessages[id] && selectAllowedMessageActions(global, chatMessages[id], threadId).canEdit); }); if (!lastOwnEditableMessageId) { return undefined; } return replaceThreadParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId); }); addActionHandler('replyToNextMessage', (global, actions, payload): ActionReturnType => { const { targetIndexDelta, tabId = getCurrentTabId() } = payload; const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; if (!chatId || !threadId) { return; } const chatMessages = selectChatMessages(global, chatId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId); if (!chatMessages || !viewportIds) { return; } const replyingToId = selectReplyingToId(global, chatId, threadId); const isLatest = selectIsViewportNewest(global, chatId, threadId, tabId); let messageId: number | undefined; if (!isLatest || !replyingToId) { if (threadId === MAIN_THREAD_ID) { const chat = selectChat(global, chatId); messageId = chat?.lastMessage?.id; } else { const threadInfo = selectThreadInfo(global, chatId, threadId); messageId = threadInfo?.lastMessageId; } } else { const chatMessageKeys = Object.keys(chatMessages); const indexOfCurrent = chatMessageKeys.indexOf(replyingToId.toString()); const newIndex = indexOfCurrent + targetIndexDelta; messageId = newIndex <= chatMessageKeys.length + 1 && newIndex >= 0 ? Number(chatMessageKeys[newIndex]) : undefined; } actions.setReplyingToId({ messageId, tabId }); actions.focusMessage({ chatId, threadId, messageId: messageId!, tabId, }); }); addActionHandler('openAudioPlayer', (global, actions, payload): ActionReturnType => { const { chatId, threadId, messageId, origin, volume, playbackRate, isMuted, tabId = getCurrentTabId(), } = payload; const tabState = selectTabState(global, tabId); return updateTabState(global, { audioPlayer: { chatId, threadId, messageId, origin: origin ?? tabState.audioPlayer.origin, volume: volume ?? tabState.audioPlayer.volume, playbackRate: playbackRate || tabState.audioPlayer.playbackRate, isMuted: isMuted || tabState.audioPlayer.isMuted, }, }, tabId); }); addActionHandler('setAudioPlayerVolume', (global, actions, payload): ActionReturnType => { const { volume, tabId = getCurrentTabId(), } = payload; return updateTabState(global, { audioPlayer: { ...selectTabState(global, tabId).audioPlayer, volume, isMuted: false, }, }, tabId); }); addActionHandler('setAudioPlayerPlaybackRate', (global, actions, payload): ActionReturnType => { const { playbackRate, isPlaybackRateActive, tabId = getCurrentTabId(), } = payload; return updateTabState(global, { audioPlayer: { ...selectTabState(global, tabId).audioPlayer, playbackRate, isPlaybackRateActive, }, }, tabId); }); addActionHandler('setAudioPlayerMuted', (global, actions, payload): ActionReturnType => { const { isMuted, tabId = getCurrentTabId(), } = payload; return updateTabState(global, { audioPlayer: { ...selectTabState(global, tabId).audioPlayer, isMuted, }, }, tabId); }); addActionHandler('setAudioPlayerOrigin', (global, actions, payload): ActionReturnType => { const { origin, tabId = getCurrentTabId(), } = payload; return updateTabState(global, { audioPlayer: { ...selectTabState(global, tabId).audioPlayer, origin, }, }, tabId); }); addActionHandler('closeAudioPlayer', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const tabState = selectTabState(global, tabId); return updateTabState(global, { audioPlayer: { volume: tabState.audioPlayer.volume, playbackRate: tabState.audioPlayer.playbackRate, isPlaybackRateActive: undefined, isMuted: tabState.audioPlayer.isMuted, }, }, tabId); }); addActionHandler('openPollResults', (global, actions, payload): ActionReturnType => { const { chatId, messageId, tabId = getCurrentTabId() } = payload; const shouldOpenInstantly = selectIsRightColumnShown(global, getIsMobile(), tabId); const tabState = selectTabState(global, tabId); if (!shouldOpenInstantly) { window.setTimeout(() => { global = getGlobal(); global = updateTabState(global, { pollResults: { chatId, messageId, voters: {}, }, }, tabId); setGlobal(global); }, POLL_RESULT_OPEN_DELAY_MS); } else if (chatId !== tabState.pollResults.chatId || messageId !== tabState.pollResults.messageId) { return updateTabState(global, { pollResults: { chatId, messageId, voters: {}, }, }, tabId); } return undefined; }); addActionHandler('closePollResults', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; return updateTabState(global, { pollResults: {}, }, tabId); }); addActionHandler('focusLastMessage', (global, actions, payload): ActionReturnType => { const { noForumTopicPanel, tabId = getCurrentTabId() } = payload || {}; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return; } const { chatId, threadId } = currentMessageList; let lastMessageId: number | undefined; if (threadId === MAIN_THREAD_ID) { const chat = selectChat(global, chatId); lastMessageId = chat?.lastMessage?.id; } else { const threadInfo = selectThreadInfo(global, chatId, threadId); lastMessageId = threadInfo?.lastMessageId; } if (!lastMessageId) { return; } actions.focusMessage({ chatId, threadId, messageId: lastMessageId, noHighlight: true, noForumTopicPanel, tabId, }); }); addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return undefined; } const { chatId, threadId } = currentMessageList; const replyStack = selectReplyStack(global, chatId, threadId, tabId); if (!replyStack || replyStack.length === 0) { actions.focusLastMessage({ noForumTopicPanel: true, tabId }); } else { const messageId = replyStack.pop(); global = replaceTabThreadParam(global, chatId, threadId, 'replyStack', [...replyStack], tabId); setGlobal(global); actions.focusMessage({ chatId, threadId, messageId: messageId!, tabId, noForumTopicPanel: true, }); } return undefined; }); addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => { const { chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, tabId = getCurrentTabId(), } = payload!; let { messageId } = payload!; if (groupedId !== undefined) { const ids = selectForwardedMessageIdsByGroupId(global, groupedChatId!, groupedId); if (ids?.length) { ([messageId] = compact(ids)); } } const currentMessageList = selectCurrentMessageList(global, tabId); const shouldSwitchChat = !currentMessageList || ( chatId !== currentMessageList.chatId || threadId !== currentMessageList.threadId || messageListType !== currentMessageList.type ); if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = undefined; } blurTimeout = window.setTimeout(() => { global = getGlobal(); global = updateFocusedMessage(global, undefined, undefined, undefined, undefined, tabId); global = updateFocusDirection(global, undefined, tabId); setGlobal(global); }, noHighlight ? FOCUS_NO_HIGHLIGHT_DURATION : FOCUS_DURATION); global = updateFocusedMessage(global, chatId, messageId, noHighlight, isResizingContainer, tabId); global = updateFocusDirection(global, undefined, tabId); if (replyMessageId) { const replyStack = selectReplyStack(global, chatId, threadId, tabId) || []; global = replaceTabThreadParam(global, chatId, threadId, 'replyStack', [...replyStack, replyMessageId], tabId); } if (shouldSwitchChat) { global = updateFocusDirection(global, FocusDirection.Static, tabId); } const viewportIds = selectViewportIds(global, chatId, threadId, tabId); if (viewportIds && viewportIds.includes(messageId)) { setGlobal(global); actions.openChat({ id: chatId, threadId, shouldReplaceHistory, noForumTopicPanel, tabId, }); return undefined; } if (shouldSwitchChat) { global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', undefined, tabId); } global = replaceTabThreadParam(global, chatId, threadId, 'outlyingIds', undefined, tabId); if (viewportIds && !shouldSwitchChat) { const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up; global = updateFocusDirection(global, direction, tabId); } setGlobal(global); actions.openChat({ id: chatId, threadId, shouldReplaceHistory, noForumTopicPanel, tabId, }); actions.loadViewportMessages({ tabId, }); return undefined; }); addActionHandler('openForwardMenu', (global, actions, payload): ActionReturnType => { const { fromChatId, messageIds, groupedId, withMyScore, tabId = getCurrentTabId(), } = payload; let groupedMessageIds; if (groupedId) { groupedMessageIds = selectMessageIdsByGroupId(global, fromChatId, groupedId); } return updateTabState(global, { forwardMessages: { fromChatId, messageIds: groupedMessageIds || messageIds, isModalShown: true, withMyScore, }, }, tabId); }); addActionHandler('changeForwardRecipient', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; return updateTabState(global, { forwardMessages: { ...selectTabState(global, tabId).forwardMessages, toChatId: undefined, isModalShown: true, noAuthors: false, noCaptions: false, }, }, tabId); }); addActionHandler('setForwardNoAuthors', (global, actions, payload): ActionReturnType => { const { noAuthors, tabId = getCurrentTabId() } = payload; const tabState = selectTabState(global, tabId); return updateTabState(global, { forwardMessages: { ...tabState.forwardMessages, noAuthors, // `noCaptions` cannot be true when `noAuthors` is false noCaptions: noAuthors && tabState.forwardMessages.noCaptions, }, }, tabId); }); addActionHandler('setForwardNoCaptions', (global, actions, payload): ActionReturnType => { const { noCaptions, tabId = getCurrentTabId() } = payload; return updateTabState(global, { forwardMessages: { ...selectTabState(global, tabId).forwardMessages, noCaptions, noAuthors: noCaptions, // On other clients `noAuthors` updates together with `noCaptions` }, }, tabId); }); addActionHandler('exitForwardMode', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; global = updateTabState(global, { forwardMessages: {}, }, tabId); setGlobal(global); }); addActionHandler('openForwardMenuForSelectedMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const tabState = selectTabState(global, tabId); if (!tabState.selectedMessages) { return; } const { chatId: fromChatId, messageIds } = tabState.selectedMessages; actions.openForwardMenu({ fromChatId, messageIds, tabId }); }); addActionHandler('cancelMessageMediaDownload', (global, actions, payload): ActionReturnType => { const { message, tabId = getCurrentTabId() } = payload; const tabState = selectTabState(global, tabId); const byChatId = tabState.activeDownloads.byChatId[message.chatId]; if (!byChatId || !byChatId.length) return; global = updateTabState(global, { activeDownloads: { byChatId: { ...tabState.activeDownloads.byChatId, [message.chatId]: byChatId.filter((id) => id !== message.id), }, }, }, tabId); setGlobal(global); }); addActionHandler('cancelMessagesMediaDownload', (global, actions, payload): ActionReturnType => { const { messages, tabId = getCurrentTabId() } = payload; const byChatId = selectTabState(global, tabId).activeDownloads.byChatId; const newByChatId: TabState['activeDownloads']['byChatId'] = {}; Object.keys(byChatId).forEach((chatId) => { newByChatId[chatId] = byChatId[chatId].filter((id) => !messages.find((message) => message.id === id)); }); return updateTabState(global, { activeDownloads: { byChatId: newByChatId, }, }, tabId); }); addActionHandler('downloadMessageMedia', (global, actions, payload): ActionReturnType => { const { message, tabId = getCurrentTabId() } = payload; const tabState = selectTabState(global, tabId); global = updateTabState(global, { activeDownloads: { byChatId: { ...tabState.activeDownloads.byChatId, [message.chatId]: [...(tabState.activeDownloads.byChatId[message.chatId] || []), message.id], }, }, }, tabId); setGlobal(global); }); addActionHandler('downloadSelectedMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const tabState = selectTabState(global, tabId); if (!tabState.selectedMessages) { return; } const { chatId, messageIds } = tabState.selectedMessages; const { threadId } = selectCurrentMessageList(global, tabId) || {}; const chatMessages = selectChatMessages(global, chatId); if (!chatMessages || !threadId) return; const messages = messageIds.map((id) => chatMessages[id]) .filter((message) => selectAllowedMessageActions(global, message, threadId).canDownload); messages.forEach((message) => actions.downloadMessageMedia({ message, tabId })); }); addActionHandler('enterMessageSelectMode', (global, actions, payload): ActionReturnType => { const { messageId, tabId = getCurrentTabId() } = payload || {}; const openChat = selectCurrentChat(global, tabId); if (!openChat) { return global; } return enterMessageSelectMode(global, openChat.id, messageId, tabId); }); addActionHandler('toggleMessageSelection', (global, actions, payload): ActionReturnType => { const { messageId, groupedId, childMessageIds, withShift, tabId = getCurrentTabId(), } = payload; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return; } const { chatId, threadId, type: messageListType } = currentMessageList; global = toggleMessageSelection( global, chatId, threadId, messageListType, messageId, groupedId, childMessageIds, withShift, tabId, ); setGlobal(global); if (selectTabState(global, tabId).shouldShowContextMenuHint) { actions.disableContextMenuHint({ tabId }); actions.showNotification({ // eslint-disable-next-line max-len message: `To **edit** or **reply**, close this menu. Then ${IS_TOUCH_ENV ? 'long tap' : 'right click'} on a message.`, tabId, }); } }); addActionHandler('disableContextMenuHint', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; if (!selectTabState(global, tabId).shouldShowContextMenuHint) { return undefined; } return updateTabState(global, { shouldShowContextMenuHint: false, }, tabId); }); addActionHandler('exitMessageSelectMode', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; return exitMessageSelectMode(global, tabId); }); addActionHandler('openPollModal', (global, actions, payload): ActionReturnType => { const { isQuiz, tabId = getCurrentTabId() } = payload || {}; return updateTabState(global, { pollModal: { isOpen: true, isQuiz, }, }, tabId); }); addActionHandler('closePollModal', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; return updateTabState(global, { pollModal: { isOpen: false, }, }, tabId); }); addActionHandler('checkVersionNotification', (global, actions): ActionReturnType => { if (RELEASE_DATETIME && Date.now() > Number(RELEASE_DATETIME) + VERSION_NOTIFICATION_DURATION) { return; } const currentVersion = APP_VERSION.split('.').slice(0, 2).join('.'); const { serviceNotifications } = global; if (serviceNotifications.find(({ version }) => version === currentVersion)) { return; } const message: Omit = { chatId: SERVICE_NOTIFICATIONS_USER_ID, date: getServerTime(), content: { text: parseMessageInput(versionNotification, true), }, isOutgoing: false, }; actions.createServiceNotification({ message: message as ApiMessage, version: currentVersion, }); }); addActionHandler('createServiceNotification', (global, actions, payload): ActionReturnType => { const { message, version } = payload; const { serviceNotifications } = global; const serviceChat = selectChat(global, SERVICE_NOTIFICATIONS_USER_ID)!; const maxId = Math.max( serviceChat.lastMessage?.id || 0, ...serviceNotifications.map(({ id }) => id), ); const fractionalPart = (serviceNotifications.length + 1) / SERVICE_NOTIFICATIONS_MAX_AMOUNT; // The fractional ID is made of the largest integer ID and an incremented fractional part const id = Math.floor(maxId) + fractionalPart; message.previousLocalId = message.id; message.id = id; const serviceNotification = { id, message, version, isUnread: true, }; global = { ...global, serviceNotifications: [ ...serviceNotifications.slice(-SERVICE_NOTIFICATIONS_MAX_AMOUNT), serviceNotification, ], }; setGlobal(global); actions.apiUpdate({ '@type': 'newMessage', id: message.id, chatId: message.chatId, message, }); }); addActionHandler('openReactorListModal', (global, actions, payload): ActionReturnType => { const { chatId, messageId, tabId = getCurrentTabId() } = payload!; return updateTabState(global, { reactorModal: { chatId, messageId }, }, tabId); }); addActionHandler('closeReactorListModal', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload; return updateTabState(global, { reactorModal: undefined, }, tabId); }); addActionHandler('openSeenByModal', (global, actions, payload): ActionReturnType => { const { chatId, messageId, tabId = getCurrentTabId() } = payload!; return updateTabState(global, { seenByModal: { chatId, messageId }, }, tabId); }); addActionHandler('closeSeenByModal', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload; return updateTabState(global, { seenByModal: undefined, }, tabId); }); addActionHandler('copySelectedMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload; const tabState = selectTabState(global, tabId); if (!tabState.selectedMessages) { return; } const { chatId, messageIds } = tabState.selectedMessages; copyTextForMessages(global, chatId, messageIds); }); addActionHandler('copyMessagesByIds', (global, actions, payload): ActionReturnType => { const { messageIds, tabId = getCurrentTabId() } = payload; const chat = selectCurrentChat(global, tabId); if (!messageIds || messageIds.length === 0 || !chat) { return; } copyTextForMessages(global, chat.id, messageIds); }); function copyTextForMessages(global: GlobalState, chatId: string, messageIds: number[]) { const { type: messageListType, threadId } = selectCurrentMessageList(global) || {}; const lang = langProvider.translate; const chatMessages = messageListType === 'scheduled' ? selectChatScheduledMessages(global, chatId) : selectChatMessages(global, chatId); if (!chatMessages || !threadId) return; const messages = messageIds .map((id) => chatMessages[id]) .filter((message) => selectAllowedMessageActions(global, message, threadId).canCopy) .sort((message1, message2) => message1.id - message2.id); const result = messages.reduce((acc, message) => { const sender = selectSender(global, message); acc.push(`> ${sender ? getSenderTitle(lang, sender) : ''}:`); acc.push(`${renderMessageSummaryHtml(lang, message)}\n`); return acc; }, [] as string[]); const resultText = messages.reduce((acc, message) => { const sender = selectSender(global, message); acc.push(`> ${sender ? getSenderTitle(lang, sender) : ''}:`); acc.push(`${getMessageSummaryText(lang, message, false, 0, undefined, true)}\n`); return acc; }, [] as string[]); copyHtmlToClipboard(result.join('\n'), resultText.join('\n')); }