ConversationView: Move call/mute functions into redux

This commit is contained in:
Scott Nonnenberg 2022-12-06 09:31:44 -08:00 committed by GitHub
parent 8fe51cc854
commit 92a512a16d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 353 additions and 287 deletions

View file

@ -1,20 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastCannotStartGroupCall({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>{i18n('GroupV2--cannot-start-group-call')}</Toast>
);
}

View file

@ -30,72 +30,6 @@ export function ToastManager({
} }
const { toastType } = toast; const { toastType } = toast;
if (toastType === ToastType.Error) {
return (
<Toast
autoDismissDisabled
onClose={hideToast}
toastAction={{
label: i18n('Toast--error--action'),
onClick: () => window.showDebugLog(),
}}
>
{i18n('Toast--error')}
</Toast>
);
}
if (toastType === ToastType.MessageBodyTooLong) {
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
}
if (toastType === ToastType.StoryReact) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--sending-reaction')}
</Toast>
);
}
if (toastType === ToastType.StoryReply) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--sending-reply')}
</Toast>
);
}
if (toastType === ToastType.StoryMuted) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--hasNoSound')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoTooLong) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-too-long')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoUnsupported) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-unsupported')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoError) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-error')}
</Toast>
);
}
if (toastType === ToastType.AddingUserToGroup) { if (toastType === ToastType.AddingUserToGroup) {
return ( return (
@ -108,21 +42,10 @@ export function ToastManager({
); );
} }
if (toastType === ToastType.UserAddedToGroup) { if (toastType === ToastType.CannotStartGroupCall) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>
{i18n( {i18n('GroupV2--cannot-start-group-call', toast.parameters)}
'AddUserToAnotherGroupModal__toast--user-added-to-group',
toast.parameters
)}
</Toast>
);
}
if (toastType === ToastType.FailedToDeleteUsername) {
return (
<Toast onClose={hideToast}>
{i18n('ProfileEditor--username--delete-general-error')}
</Toast> </Toast>
); );
} }
@ -147,5 +70,91 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('deleteForEveryoneFailed')}</Toast>; return <Toast onClose={hideToast}>{i18n('deleteForEveryoneFailed')}</Toast>;
} }
if (toastType === ToastType.Error) {
return (
<Toast
autoDismissDisabled
onClose={hideToast}
toastAction={{
label: i18n('Toast--error--action'),
onClick: () => window.showDebugLog(),
}}
>
{i18n('Toast--error')}
</Toast>
);
}
if (toastType === ToastType.FailedToDeleteUsername) {
return (
<Toast onClose={hideToast}>
{i18n('ProfileEditor--username--delete-general-error')}
</Toast>
);
}
if (toastType === ToastType.MessageBodyTooLong) {
return <ToastMessageBodyTooLong i18n={i18n} onClose={hideToast} />;
}
if (toastType === ToastType.StoryMuted) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--hasNoSound')}
</Toast>
);
}
if (toastType === ToastType.StoryReact) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--sending-reaction')}
</Toast>
);
}
if (toastType === ToastType.StoryReply) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('Stories__toast--sending-reply')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoError) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-error')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoTooLong) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-too-long')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoUnsupported) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-unsupported')}
</Toast>
);
}
if (toastType === ToastType.UserAddedToGroup) {
return (
<Toast onClose={hideToast}>
{i18n(
'AddUserToAnotherGroupModal__toast--user-added-to-group',
toast.parameters
)}
</Toast>
);
}
throw missingCaseError(toastType); throw missingCaseError(toastType);
} }

View file

@ -41,7 +41,6 @@ const commonProps = {
setDisappearingMessages: action('setDisappearingMessages'), setDisappearingMessages: action('setDisappearingMessages'),
destroyMessages: action('destroyMessages'), destroyMessages: action('destroyMessages'),
onSearchInConversation: action('onSearchInConversation'), onSearchInConversation: action('onSearchInConversation'),
onSetMuteNotifications: action('onSetMuteNotifications'),
onOutgoingAudioCallInConversation: action( onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation' 'onOutgoingAudioCallInConversation'
), ),
@ -57,6 +56,7 @@ const commonProps = {
onMarkUnread: action('onMarkUnread'), onMarkUnread: action('onMarkUnread'),
onMoveToInbox: action('onMoveToInbox'), onMoveToInbox: action('onMoveToInbox'),
onSetPin: action('onSetPin'), onSetPin: action('onSetPin'),
setMuteExpiration: action('onSetMuteNotifications'),
viewUserStories: action('viewUserStories'), viewUserStories: action('viewUserStories'),
}; };

View file

@ -80,11 +80,10 @@ export type PropsDataType = {
>; >;
export type PropsActionsType = { export type PropsActionsType = {
onSetMuteNotifications: (seconds: number) => void;
destroyMessages: (conversationId: string) => void; destroyMessages: (conversationId: string) => void;
onSearchInConversation: () => void; onSearchInConversation: () => void;
onOutgoingAudioCallInConversation: () => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: () => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
onSetPin: (value: boolean) => void; onSetPin: (value: boolean) => void;
onShowConversationDetails: () => void; onShowConversationDetails: () => void;
@ -95,6 +94,7 @@ export type PropsActionsType = {
onArchive: () => void; onArchive: () => void;
onMarkUnread: () => void; onMarkUnread: () => void;
onMoveToInbox: () => void; onMoveToInbox: () => void;
setMuteExpiration: (conversationId: string, seconds: number) => void;
setDisappearingMessages: ( setDisappearingMessages: (
conversationId: string, conversationId: string,
seconds: DurationInSeconds seconds: DurationInSeconds
@ -349,12 +349,12 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
onArchive, onArchive,
onMarkUnread, onMarkUnread,
onMoveToInbox, onMoveToInbox,
onSetMuteNotifications,
onSetPin, onSetPin,
onShowAllMedia, onShowAllMedia,
onShowConversationDetails, onShowConversationDetails,
onShowGroupMembers, onShowGroupMembers,
setDisappearingMessages, setDisappearingMessages,
setMuteExpiration,
type, type,
} = this.props; } = this.props;
@ -371,7 +371,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{isMuted ? ( {isMuted ? (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onSetMuteNotifications(0); setMuteExpiration(id, 0);
}} }}
> >
{i18n('unmute')} {i18n('unmute')}
@ -379,7 +379,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
) : ( ) : (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onSetMuteNotifications(Number.MAX_SAFE_INTEGER); setMuteExpiration(id, Number.MAX_SAFE_INTEGER);
}} }}
> >
{i18n('muteAlways')} {i18n('muteAlways')}
@ -465,7 +465,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
key={item.name} key={item.name}
disabled={item.disabled} disabled={item.disabled}
onClick={() => { onClick={() => {
onSetMuteNotifications(item.value); setMuteExpiration(id, item.value);
}} }}
> >
{item.name} {item.name}
@ -676,6 +676,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
announcementsOnly={announcementsOnly} announcementsOnly={announcementsOnly}
areWeAdmin={areWeAdmin} areWeAdmin={areWeAdmin}
i18n={i18n} i18n={i18n}
id={id}
isNarrow={isNarrow} isNarrow={isNarrow}
onOutgoingAudioCallInConversation={ onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation onOutgoingAudioCallInConversation
@ -702,6 +703,7 @@ function OutgoingCallButtons({
announcementsOnly, announcementsOnly,
areWeAdmin, areWeAdmin,
i18n, i18n,
id,
isNarrow, isNarrow,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
@ -712,6 +714,7 @@ function OutgoingCallButtons({
| 'announcementsOnly' | 'announcementsOnly'
| 'areWeAdmin' | 'areWeAdmin'
| 'i18n' | 'i18n'
| 'id'
| 'onOutgoingAudioCallInConversation' | 'onOutgoingAudioCallInConversation'
| 'onOutgoingVideoCallInConversation' | 'onOutgoingVideoCallInConversation'
| 'outgoingCallButtonStyle' | 'outgoingCallButtonStyle'
@ -729,14 +732,14 @@ function OutgoingCallButtons({
: undefined : undefined
)} )}
disabled={showBackButton} disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation} onClick={() => onOutgoingVideoCallInConversation(id)}
type="button" type="button"
/> />
); );
const startCallShortcuts = useStartCallShortcuts( const startCallShortcuts = useStartCallShortcuts(
onOutgoingAudioCallInConversation, () => onOutgoingAudioCallInConversation(id),
onOutgoingVideoCallInConversation () => onOutgoingVideoCallInConversation(id)
); );
useKeyboardShortcuts(startCallShortcuts); useKeyboardShortcuts(startCallShortcuts);
@ -751,7 +754,7 @@ function OutgoingCallButtons({
{videoButton} {videoButton}
<button <button
type="button" type="button"
onClick={onOutgoingAudioCallInConversation} onClick={() => onOutgoingAudioCallInConversation(id)}
className={classNames( className={classNames(
'module-ConversationHeader__button', 'module-ConversationHeader__button',
'module-ConversationHeader__button--audio', 'module-ConversationHeader__button--audio',
@ -772,7 +775,7 @@ function OutgoingCallButtons({
showBackButton ? null : 'module-ConversationHeader__button--show' showBackButton ? null : 'module-ConversationHeader__button--show'
)} )}
disabled={showBackButton} disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation} onClick={() => onOutgoingVideoCallInConversation(id)}
type="button" type="button"
> >
{isNarrow ? null : i18n('joinOngoingCall')} {isNarrow ? null : i18n('joinOngoingCall')}

View file

@ -101,9 +101,6 @@ export type StateProps = {
onUnblock: () => void; onUnblock: () => void;
theme: ThemeType; theme: ThemeType;
userAvatarData: Array<AvatarDataType>; userAvatarData: Array<AvatarDataType>;
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
onOutgoingAudioCallInConversation: () => unknown;
onOutgoingVideoCallInConversation: () => unknown;
renderChooseGroupMembersModal: ( renderChooseGroupMembersModal: (
props: SmartChooseGroupMembersModalPropsType props: SmartChooseGroupMembersModalPropsType
) => JSX.Element; ) => JSX.Element;
@ -115,10 +112,13 @@ export type StateProps = {
type ActionProps = { type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
loadRecentMediaItems: (id: string, limit: number) => void; loadRecentMediaItems: (id: string, limit: number) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
onOutgoingVideoCallInConversation: (conversationId: string) => unknown;
replaceAvatar: ReplaceAvatarActionType; replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType; saveAvatarToDisk: SaveAvatarToDiskActionType;
searchInConversation: (id: string) => unknown; searchInConversation: (id: string) => unknown;
setDisappearingMessages: (id: string, seconds: DurationInSeconds) => void; setDisappearingMessages: (id: string, seconds: DurationInSeconds) => void;
setMuteExpiration: (id: string, muteExpiresAt: undefined | number) => unknown;
showContactModal: (contactId: string, conversationId?: string) => void; showContactModal: (contactId: string, conversationId?: string) => void;
showConversation: ShowConversationType; showConversation: ShowConversationType;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void; toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
@ -291,6 +291,7 @@ export function ConversationDetails({
modalNode = ( modalNode = (
<ConversationNotificationsModal <ConversationNotificationsModal
i18n={i18n} i18n={i18n}
id={conversation.id}
muteExpiresAt={conversation.muteExpiresAt} muteExpiresAt={conversation.muteExpiresAt}
onClose={() => { onClose={() => {
setModalState(ModalState.NothingOpen); setModalState(ModalState.NothingOpen);
@ -305,7 +306,7 @@ export function ConversationDetails({
dialogName="ConversationDetails.unmuteNotifications" dialogName="ConversationDetails.unmuteNotifications"
actions={[ actions={[
{ {
action: () => setMuteExpiration(0), action: () => setMuteExpiration(conversation.id, 0),
style: 'affirmative', style: 'affirmative',
text: i18n('unmute'), text: i18n('unmute'),
}, },
@ -354,14 +355,16 @@ export function ConversationDetails({
<ConversationDetailsCallButton <ConversationDetailsCallButton
disabled={hasActiveCall} disabled={hasActiveCall}
i18n={i18n} i18n={i18n}
onClick={onOutgoingVideoCallInConversation} onClick={() => onOutgoingVideoCallInConversation(conversation.id)}
type="video" type="video"
/> />
{!isGroup && ( {!isGroup && (
<ConversationDetailsCallButton <ConversationDetailsCallButton
disabled={hasActiveCall} disabled={hasActiveCall}
i18n={i18n} i18n={i18n}
onClick={onOutgoingAudioCallInConversation} onClick={() =>
onOutgoingAudioCallInConversation(conversation.id)
}
type="audio" type="audio"
/> />
)} )}

View file

@ -12,13 +12,18 @@ import { Button, ButtonVariant } from '../../Button';
type PropsType = { type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
id: string;
muteExpiresAt: undefined | number; muteExpiresAt: undefined | number;
onClose: () => unknown; onClose: () => unknown;
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; setMuteExpiration: (
conversationId: string,
muteExpiresAt: undefined | number
) => unknown;
}; };
export function ConversationNotificationsModal({ export function ConversationNotificationsModal({
i18n, i18n,
id,
muteExpiresAt, muteExpiresAt,
onClose, onClose,
setMuteExpiration, setMuteExpiration,
@ -40,7 +45,7 @@ export function ConversationNotificationsModal({
muteExpirationValue, muteExpirationValue,
'NotificationSettings: mute ms was not an integer' 'NotificationSettings: mute ms was not an integer'
); );
setMuteExpiration(ms); setMuteExpiration(id, ms);
onClose(); onClose();
}; };

View file

@ -17,6 +17,7 @@ export default {
}; };
const getCommonProps = () => ({ const getCommonProps = () => ({
id: 'conversation-id',
muteExpiresAt: undefined, muteExpiresAt: undefined,
conversationType: 'group' as const, conversationType: 'group' as const,
dontNotifyForMentionsIfMuted: false, dontNotifyForMentionsIfMuted: false,

View file

@ -15,17 +15,23 @@ import { parseIntOrThrow } from '../../../util/parseIntOrThrow';
import { useUniqueId } from '../../../hooks/useUniqueId'; import { useUniqueId } from '../../../hooks/useUniqueId';
type PropsType = { type PropsType = {
id: string;
conversationType: ConversationTypeType; conversationType: ConversationTypeType;
dontNotifyForMentionsIfMuted: boolean; dontNotifyForMentionsIfMuted: boolean;
i18n: LocalizerType; i18n: LocalizerType;
muteExpiresAt: undefined | number; muteExpiresAt: undefined | number;
setDontNotifyForMentionsIfMuted: ( setDontNotifyForMentionsIfMuted: (
conversationId: string,
dontNotifyForMentionsIfMuted: boolean dontNotifyForMentionsIfMuted: boolean
) => unknown; ) => unknown;
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; setMuteExpiration: (
conversationId: string,
muteExpiresAt: undefined | number
) => unknown;
}; };
export function ConversationNotificationsSettings({ export function ConversationNotificationsSettings({
id,
conversationType, conversationType,
dontNotifyForMentionsIfMuted, dontNotifyForMentionsIfMuted,
i18n, i18n,
@ -62,11 +68,11 @@ export function ConversationNotificationsSettings({
rawValue, rawValue,
'NotificationSettings: mute ms was not an integer' 'NotificationSettings: mute ms was not an integer'
); );
setMuteExpiration(ms); setMuteExpiration(id, ms);
}; };
const onChangeDontNotifyForMentionsIfMuted = (rawValue: string) => { const onChangeDontNotifyForMentionsIfMuted = (rawValue: string) => {
setDontNotifyForMentionsIfMuted(rawValue === 'yes'); setDontNotifyForMentionsIfMuted(id, rawValue === 'yes');
}; };
return ( return (

View file

@ -47,6 +47,10 @@ import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { waitForOnline } from '../../util/waitForOnline'; import { waitForOnline } from '../../util/waitForOnline';
import * as mapUtil from '../../util/mapUtil'; import * as mapUtil from '../../util/mapUtil';
import { isCallSafe } from '../../util/isCallSafe';
import { isDirectConversation } from '../../util/whatTypeOfConversation';
import { SHOW_TOAST, ToastType } from './toast';
import type { ShowToastActionType } from './toast';
// State // State
@ -1189,6 +1193,106 @@ function setOutgoingRing(payload: boolean): SetOutgoingRingActionType {
}; };
} }
function onOutgoingVideoCallInConversation(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
StartCallingLobbyActionType | ShowToastActionType
> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`onOutgoingVideoCallInConversation: No conversation found for conversation ${conversationId}`
);
}
log.info('onOutgoingVideoCallInConversation: about to start a video call');
// if it's a group call on an announcementsOnly group
// only allow join if the call has already been started (presumably by the admin)
if (conversation.get('announcementsOnly') && !conversation.areWeAdmin()) {
const call = getOwn(
getState().calling.callsByConversation,
conversationId
);
// technically not necessary, but isAnybodyElseInGroupCall requires it
const ourUuid = window.storage.user.getCheckedUuid().toString();
const isOngoingGroupCall =
call &&
ourUuid &&
call.callMode === CallMode.Group &&
call.peekInfo &&
isAnybodyElseInGroupCall(call.peekInfo, ourUuid);
if (!isOngoingGroupCall) {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.CannotStartGroupCall,
},
});
return;
}
}
if (await isCallSafe(conversation.attributes)) {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
startCallingLobby({
conversationId,
isVideoCall: true,
})(dispatch, getState, undefined);
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
};
}
function onOutgoingAudioCallInConversation(
conversationId: string
): ThunkAction<void, RootStateType, unknown, StartCallingLobbyActionType> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`onOutgoingAudioCallInConversation: No conversation found for conversation ${conversationId}`
);
}
if (!isDirectConversation(conversation.attributes)) {
throw new Error(
`onOutgoingAudioCallInConversation: Conversation ${conversation.idForLogging()} is not 1:1`
);
}
log.info('onOutgoingAudioCallInConversation: about to start an audio call');
if (await isCallSafe(conversation.attributes)) {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
startCallingLobby({
conversationId,
isVideoCall: false,
})(dispatch, getState, undefined);
log.info('onOutgoingAudioCallInConversation: started the call');
} else {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
};
}
function startCallingLobby({ function startCallingLobby({
conversationId, conversationId,
isVideoCall, isVideoCall,
@ -1346,6 +1450,8 @@ export const actions = {
hangUpActiveCall, hangUpActiveCall,
keyChangeOk, keyChangeOk,
keyChanged, keyChanged,
onOutgoingVideoCallInConversation,
onOutgoingAudioCallInConversation,
openSystemPreferencesAction, openSystemPreferencesAction,
outgoingCall, outgoingCall,
peekGroupCallForTheFirstTime, peekGroupCallForTheFirstTime,

View file

@ -897,9 +897,11 @@ export const actions = {
setComposeGroupName, setComposeGroupName,
setComposeSearchTerm, setComposeSearchTerm,
setDisappearingMessages, setDisappearingMessages,
setDontNotifyForMentionsIfMuted,
setIsFetchingUUID, setIsFetchingUUID,
setIsNearBottom, setIsNearBottom,
setMessageLoadingState, setMessageLoadingState,
setMuteExpiration,
setPreJoinConversation, setPreJoinConversation,
setSelectedConversationHeaderTitle, setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth, setSelectedConversationPanelDepth,
@ -943,7 +945,7 @@ async function getAvatarsAndUpdateConversation(
): Promise<Array<AvatarDataType>> { ): Promise<Array<AvatarDataType>> {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('getAvatarsAndUpdateConversation: No conversation found');
} }
const { conversationLookup } = conversations; const { conversationLookup } = conversations;
@ -1004,7 +1006,7 @@ function changeHasGroupLink(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('changeHasGroupLink: No conversation found');
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1026,7 +1028,7 @@ function setAnnouncementsOnly(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('setAnnouncementsOnly: No conversation found');
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1048,7 +1050,7 @@ function setAccessControlMembersSetting(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('setAccessControlMembersSetting: No conversation found');
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1070,7 +1072,9 @@ function setAccessControlAttributesSetting(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error(
'setAccessControlAttributesSetting: No conversation found'
);
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1092,7 +1096,7 @@ function setDisappearingMessages(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('setDisappearingMessages: No conversation found');
} }
const valueToSet = seconds > 0 ? seconds : undefined; const valueToSet = seconds > 0 ? seconds : undefined;
@ -1112,13 +1116,51 @@ function setDisappearingMessages(
}; };
} }
function setDontNotifyForMentionsIfMuted(
conversationId: string,
newValue: boolean
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setDontNotifyForMentionsIfMuted: No conversation found');
}
conversation.setDontNotifyForMentionsIfMuted(newValue);
return {
type: 'NOOP',
payload: null,
};
}
function setMuteExpiration(
conversationId: string,
muteExpiresAt = 0
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setMuteExpiration: No conversation found');
}
conversation.setMuteExpiration(
muteExpiresAt >= Number.MAX_SAFE_INTEGER
? muteExpiresAt
: Date.now() + muteExpiresAt
);
return {
type: 'NOOP',
payload: null,
};
}
function destroyMessages( function destroyMessages(
conversationId: string conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('destroyMessages: No conversation found');
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1144,7 +1186,7 @@ function generateNewGroupLink(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error('generateNewGroupLink: No conversation found');
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1217,7 +1259,9 @@ function setAccessControlAddFromInviteLinkSetting(
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error('No conversation found'); throw new Error(
'setAccessControlAddFromInviteLinkSetting: No conversation found'
);
} }
await longRunningTaskWrapper({ await longRunningTaskWrapper({
@ -1280,7 +1324,7 @@ function saveAvatarToDisk(
): ThunkAction<void, RootStateType, unknown, ReplaceAvatarsActionType> { ): ThunkAction<void, RootStateType, unknown, ReplaceAvatarsActionType> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
if (!avatarData.buffer) { if (!avatarData.buffer) {
throw new Error('No avatar Uint8Array provided'); throw new Error('saveAvatarToDisk: No avatar Uint8Array provided');
} }
strictAssert(conversationId, 'conversationId not provided'); strictAssert(conversationId, 'conversationId not provided');

View file

@ -5,7 +5,13 @@ import { useBoundActions } from '../../hooks/useBoundActions';
import type { ReplacementValuesType } from '../../types/Util'; import type { ReplacementValuesType } from '../../types/Util';
export enum ToastType { export enum ToastType {
AddingUserToGroup = 'AddingUserToGroup',
CannotStartGroupCall = 'CannotStartGroupCall',
CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
Error = 'Error', Error = 'Error',
FailedToDeleteUsername = 'FailedToDeleteUsername',
MessageBodyTooLong = 'MessageBodyTooLong', MessageBodyTooLong = 'MessageBodyTooLong',
StoryMuted = 'StoryMuted', StoryMuted = 'StoryMuted',
StoryReact = 'StoryReact', StoryReact = 'StoryReact',
@ -13,12 +19,7 @@ export enum ToastType {
StoryVideoError = 'StoryVideoError', StoryVideoError = 'StoryVideoError',
StoryVideoTooLong = 'StoryVideoTooLong', StoryVideoTooLong = 'StoryVideoTooLong',
StoryVideoUnsupported = 'StoryVideoUnsupported', StoryVideoUnsupported = 'StoryVideoUnsupported',
AddingUserToGroup = 'AddingUserToGroup',
UserAddedToGroup = 'UserAddedToGroup', UserAddedToGroup = 'UserAddedToGroup',
FailedToDeleteUsername = 'FailedToDeleteUsername',
CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
} }
// State // State

View file

@ -57,9 +57,6 @@ export type SmartConversationDetailsProps = {
onBlock: () => void; onBlock: () => void;
onLeave: () => void; onLeave: () => void;
onUnblock: () => void; onUnblock: () => void;
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
onOutgoingAudioCallInConversation: () => unknown;
onOutgoingVideoCallInConversation: () => unknown;
}; };
const ACCESS_ENUM = Proto.AccessControl.AccessRequired; const ACCESS_ENUM = Proto.AccessControl.AccessRequired;

View file

@ -33,10 +33,7 @@ export type OwnProps = {
onGoBack: () => void; onGoBack: () => void;
onMarkUnread: () => void; onMarkUnread: () => void;
onMoveToInbox: () => void; onMoveToInbox: () => void;
onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void;
onSearchInConversation: () => void; onSearchInConversation: () => void;
onSetMuteNotifications: (seconds: number) => void;
onSetPin: (value: boolean) => void; onSetPin: (value: boolean) => void;
onShowAllMedia: () => void; onShowAllMedia: () => void;
onShowConversationDetails: () => void; onShowConversationDetails: () => void;

View file

@ -7,36 +7,31 @@ import type { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getConversationByIdSelector } from '../selectors/conversations'; import { getConversationByIdSelector } from '../selectors/conversations';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { mapDispatchToProps } from '../actions';
export type OwnProps = { export type OwnProps = {
conversationId: string; conversationId: string;
setDontNotifyForMentionsIfMuted: (
dontNotifyForMentionsIfMuted: boolean
) => unknown;
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
}; };
const mapStateToProps = (state: StateType, props: OwnProps) => { const mapStateToProps = (state: StateType, props: OwnProps) => {
const { conversationId, setDontNotifyForMentionsIfMuted, setMuteExpiration } = const { conversationId } = props;
props;
const conversationSelector = getConversationByIdSelector(state); const conversationSelector = getConversationByIdSelector(state);
const conversation = conversationSelector(conversationId); const conversation = conversationSelector(conversationId);
strictAssert(conversation, 'Expected a conversation to be found'); strictAssert(conversation, 'Expected a conversation to be found');
return { return {
id: conversationId,
conversationType: conversation.type, conversationType: conversation.type,
dontNotifyForMentionsIfMuted: Boolean( dontNotifyForMentionsIfMuted: Boolean(
conversation.dontNotifyForMentionsIfMuted conversation.dontNotifyForMentionsIfMuted
), ),
i18n: getIntl(state), i18n: getIntl(state),
muteExpiresAt: conversation.muteExpiresAt, muteExpiresAt: conversation.muteExpiresAt,
setDontNotifyForMentionsIfMuted,
setMuteExpiration,
}; };
}; };
const smart = connect(mapStateToProps, {}); const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartConversationNotificationsSettings = smart( export const SmartConversationNotificationsSettings = smart(
ConversationNotificationsSettings ConversationNotificationsSettings

38
ts/util/isCallSafe.ts Normal file
View file

@ -0,0 +1,38 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types';
import type { RecipientsByConversation } from '../state/ducks/stories';
import * as log from '../logging/log';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { blockSendUntilConversationsAreVerified } from './blockSendUntilConversationsAreVerified';
import { getConversationMembers } from './getConversationMembers';
import { UUID } from '../types/UUID';
import { isNotNil } from './isNotNil';
export async function isCallSafe(
attributes: ConversationAttributesType
): Promise<boolean> {
const recipientsByConversation: RecipientsByConversation = {
[attributes.id]: {
uuids: getConversationMembers(attributes)
.map(member =>
member.uuid ? UUID.checkedLookup(member.uuid).toString() : undefined
)
.filter(isNotNil),
},
};
const callAnyway = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.Calling
);
if (!callAnyway) {
log.info('Safety number change dialog not accepted, new call not allowed.');
return false;
}
return true;
}

View file

@ -13,7 +13,6 @@ import type {
ToastCannotOpenGiftBadge, ToastCannotOpenGiftBadge,
ToastPropsType as ToastCannotOpenGiftBadgePropsType, ToastPropsType as ToastCannotOpenGiftBadgePropsType,
} from '../components/ToastCannotOpenGiftBadge'; } from '../components/ToastCannotOpenGiftBadge';
import type { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall';
import type { ToastCaptchaFailed } from '../components/ToastCaptchaFailed'; import type { ToastCaptchaFailed } from '../components/ToastCaptchaFailed';
import type { ToastCaptchaSolved } from '../components/ToastCaptchaSolved'; import type { ToastCaptchaSolved } from '../components/ToastCaptchaSolved';
import type { import type {
@ -60,7 +59,6 @@ export function showToast(Toast: typeof ToastAlreadyRequestedToJoin): void;
export function showToast(Toast: typeof ToastBlocked): void; export function showToast(Toast: typeof ToastBlocked): void;
export function showToast(Toast: typeof ToastBlockedGroup): void; export function showToast(Toast: typeof ToastBlockedGroup): void;
export function showToast(Toast: typeof ToastUnsupportedMultiAttachment): void; export function showToast(Toast: typeof ToastUnsupportedMultiAttachment): void;
export function showToast(Toast: typeof ToastCannotStartGroupCall): void;
export function showToast( export function showToast(
Toast: typeof ToastCannotOpenGiftBadge, Toast: typeof ToastCannotOpenGiftBadge,
props: Omit<ToastCannotOpenGiftBadgePropsType, 'i18n' | 'onClose'> props: Omit<ToastCannotOpenGiftBadgePropsType, 'i18n' | 'onClose'>

View file

@ -61,7 +61,6 @@ import { SignalService as Proto } from '../protobuf';
import { ToastBlocked } from '../components/ToastBlocked'; import { ToastBlocked } from '../components/ToastBlocked';
import { ToastBlockedGroup } from '../components/ToastBlockedGroup'; import { ToastBlockedGroup } from '../components/ToastBlockedGroup';
import { ToastCannotMixMultiAndNonMultiAttachments } from '../components/ToastCannotMixMultiAndNonMultiAttachments'; import { ToastCannotMixMultiAndNonMultiAttachments } from '../components/ToastCannotMixMultiAndNonMultiAttachments';
import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall';
import { ToastConversationArchived } from '../components/ToastConversationArchived'; import { ToastConversationArchived } from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
@ -108,10 +107,8 @@ import { saveAttachment } from '../util/saveAttachment';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified'; import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog'; import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { getOwn } from '../util/getOwn';
import { CallMode } from '../types/Calling';
import { isAnybodyElseInGroupCall } from '../state/ducks/calling';
import { startConversation } from '../util/startConversation'; import { startConversation } from '../util/startConversation';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
type AttachmentOptions = { type AttachmentOptions = {
messageId: string; messageId: string;
@ -306,12 +303,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
return this; return this;
} }
setMuteExpiration(ms = 0): void {
this.model.setMuteExpiration(
ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms
);
}
setPin(value: boolean): void { setPin(value: boolean): void {
if (value) { if (value) {
const pinnedConversationIds = window.storage.get( const pinnedConversationIds = window.storage.get(
@ -338,15 +329,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const { searchInConversation } = window.reduxActions.search; const { searchInConversation } = window.reduxActions.search;
searchInConversation(this.model.id); searchInConversation(this.model.id);
}, },
onSetMuteNotifications: this.setMuteExpiration.bind(this),
onSetPin: this.setPin.bind(this), onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they
// need a manual update call.
onOutgoingAudioCallInConversation:
this.onOutgoingAudioCallInConversation.bind(this),
onOutgoingVideoCallInConversation:
this.onOutgoingVideoCallInConversation.bind(this),
onShowConversationDetails: () => { onShowConversationDetails: () => {
this.showConversationDetails(); this.showConversationDetails();
}, },
@ -505,7 +488,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
messageRequestEnum.ACCEPT messageRequestEnum.ACCEPT
), ),
removeMember: (conversationId: string) => { removeMember: (conversationId: string) => {
this.longRunningTaskWrapper({ longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name: 'removeMember', name: 'removeMember',
task: () => this.model.removeFromGroupV2(conversationId), task: () => this.model.removeFromGroupV2(conversationId),
}); });
@ -575,7 +559,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'), okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'),
cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'), cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'),
resolve: () => { resolve: () => {
this.longRunningTaskWrapper({ longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name: 'onCancelJoinRequest', name: 'onCancelJoinRequest',
task: async () => this.model.cancelJoinRequest(), task: async () => this.model.cancelJoinRequest(),
}); });
@ -630,83 +615,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.$('.ConversationView__template').append(this.conversationView.el); this.$('.ConversationView__template').append(this.conversationView.el);
} }
async onOutgoingVideoCallInConversation(): Promise<void> {
log.info('onOutgoingVideoCallInConversation: about to start a video call');
// if it's a group call on an announcementsOnly group
// only allow join if the call has already been started (presumably by the admin)
if (this.model.get('announcementsOnly') && !this.model.areWeAdmin()) {
const call = getOwn(
window.reduxStore.getState().calling.callsByConversation,
this.model.id
);
// technically not necessary, but isAnybodyElseInGroupCall requires it
const ourUuid = window.storage.user.getCheckedUuid().toString();
const isOngoingGroupCall =
call &&
ourUuid &&
call.callMode === CallMode.Group &&
call.peekInfo &&
isAnybodyElseInGroupCall(call.peekInfo, ourUuid);
if (!isOngoingGroupCall) {
showToast(ToastCannotStartGroupCall);
return;
}
}
if (await this.isCallSafe()) {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
window.reduxActions.calling.startCallingLobby({
conversationId: this.model.id,
isVideoCall: true,
});
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
}
async onOutgoingAudioCallInConversation(): Promise<void> {
log.info('onOutgoingAudioCallInConversation: about to start an audio call');
if (await this.isCallSafe()) {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
window.reduxActions.calling.startCallingLobby({
conversationId: this.model.id,
isVideoCall: false,
});
log.info('onOutgoingAudioCallInConversation: started the call');
} else {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
}
async longRunningTaskWrapper<T>({
name,
task,
}: {
name: string;
task: () => Promise<T>;
}): Promise<T> {
const idForLogging = this.model.idForLogging();
return window.Signal.Util.longRunningTaskWrapper({
name,
idForLogging,
task,
});
}
getMessageActions(): MessageActionsType { getMessageActions(): MessageActionsType {
const reactToMessage = async ( const reactToMessage = async (
messageId: string, messageId: string,
@ -896,7 +804,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const migrate = () => { const migrate = () => {
onClose(); onClose();
this.longRunningTaskWrapper({ longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name: 'initiateMigrationToGroupV2', name: 'initiateMigrationToGroupV2',
task: () => window.Signal.Groups.initiateMigrationToGroupV2(this.model), task: () => window.Signal.Groups.initiateMigrationToGroupV2(this.model),
}); });
@ -905,7 +814,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// Note: this call will throw if, after generating member lists, we are no longer a // Note: this call will throw if, after generating member lists, we are no longer a
// member or are in the pending member list. // member or are in the pending member list.
const { droppedGV2MemberIds, pendingMembersV2 } = const { droppedGV2MemberIds, pendingMembersV2 } =
await this.longRunningTaskWrapper({ await longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name: 'getGroupMigrationMembers', name: 'getGroupMigrationMembers',
task: () => window.Signal.Groups.getGroupMigrationMembers(this.model), task: () => window.Signal.Groups.getGroupMigrationMembers(this.model),
}); });
@ -1103,7 +1013,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
model: ConversationModel, model: ConversationModel,
messageRequestType: number messageRequestType: number
): Promise<void> { ): Promise<void> {
return this.longRunningTaskWrapper({ return longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name, name,
task: model.syncMessageRequestResponse.bind(model, messageRequestType), task: model.syncMessageRequestResponse.bind(model, messageRequestType),
}); });
@ -1112,7 +1023,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
blockAndReportSpam(model: ConversationModel): Promise<void> { blockAndReportSpam(model: ConversationModel): Promise<void> {
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
return this.longRunningTaskWrapper({ return longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name: 'blockAndReportSpam', name: 'blockAndReportSpam',
task: async () => { task: async () => {
await Promise.all([ await Promise.all([
@ -1862,9 +1774,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.reduxStore, window.reduxStore,
{ {
conversationId: this.model.id, conversationId: this.model.id,
setDontNotifyForMentionsIfMuted:
this.model.setDontNotifyForMentionsIfMuted.bind(this.model),
setMuteExpiration: this.setMuteExpiration.bind(this),
} }
), ),
}); });
@ -1900,7 +1809,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// dried up and hoisted to methods on ConversationView // dried up and hoisted to methods on ConversationView
const onLeave = () => { const onLeave = () => {
this.longRunningTaskWrapper({ longRunningTaskWrapper({
idForLogging: this.model.idForLogging(),
name: 'onLeave', name: 'onLeave',
task: () => this.model.leaveGroupV2(), task: () => this.model.leaveGroupV2(),
}); });
@ -1938,11 +1848,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
messageRequestEnum.ACCEPT messageRequestEnum.ACCEPT
); );
}, },
setMuteExpiration: this.setMuteExpiration.bind(this),
onOutgoingAudioCallInConversation:
this.onOutgoingAudioCallInConversation.bind(this),
onOutgoingVideoCallInConversation:
this.onOutgoingVideoCallInConversation.bind(this),
}; };
const view = new ReactWrapperView({ const view = new ReactWrapperView({
@ -2128,28 +2033,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
); );
} }
async isCallSafe(): Promise<boolean> {
const recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
const callAnyway = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.Calling
);
if (!callAnyway) {
log.info(
'Safety number change dialog not accepted, new call not allowed.'
);
return false;
}
return true;
}
async sendStickerMessage(options: { async sendStickerMessage(options: {
packId: string; packId: string;
stickerId: number; stickerId: number;