Voice Notes mini-player: Show with no conversation, fix spacing
This commit is contained in:
parent
9015837b2e
commit
75d5e81013
10 changed files with 159 additions and 87 deletions
|
@ -24,6 +24,8 @@
|
|||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 8px 16px;
|
||||
margin-top: calc(52px + var(--title-bar-drag-area-height));
|
||||
text-align: left;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-02;
|
||||
|
@ -32,6 +34,11 @@
|
|||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
&--flow {
|
||||
margin-top: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__playback-button {
|
||||
@include button-reset;
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ export type PropsType = {
|
|||
renderConversationView: () => JSX.Element;
|
||||
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
||||
renderLeftPane: () => JSX.Element;
|
||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||
selectedConversationId?: string;
|
||||
selectedMessage?: string;
|
||||
|
@ -42,6 +43,7 @@ export function Inbox({
|
|||
renderConversationView,
|
||||
renderCustomizingPreferredReactionsModal,
|
||||
renderLeftPane,
|
||||
renderMiniPlayer,
|
||||
scrollToMessage,
|
||||
selectedConversationId,
|
||||
selectedMessage,
|
||||
|
@ -219,6 +221,7 @@ export function Inbox({
|
|||
)}
|
||||
{!prevConversationId && (
|
||||
<div className="no-conversation-open">
|
||||
{renderMiniPlayer({ shouldFlow: false })}
|
||||
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
|
||||
<h3>{i18n('welcomeToSignal')}</h3>
|
||||
<p className="whats-new-placeholder">
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import classnames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { durationToPlaybackText } from '../util/durationToPlaybackText';
|
||||
|
@ -22,6 +23,8 @@ export type Props = Readonly<{
|
|||
duration: number | undefined;
|
||||
playbackRate: number;
|
||||
state: PlayerState;
|
||||
// if false or not provided, position:absolute. Otherwise, it's position: relative
|
||||
shouldFlow?: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onPlaybackRate: (rate: number) => void;
|
||||
|
@ -35,6 +38,7 @@ export function MiniPlayer({
|
|||
currentTime,
|
||||
duration,
|
||||
playbackRate,
|
||||
shouldFlow,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPlaybackRate,
|
||||
|
@ -79,7 +83,12 @@ export function MiniPlayer({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="MiniPlayer">
|
||||
<div
|
||||
className={classnames(
|
||||
'MiniPlayer',
|
||||
shouldFlow ? 'MiniPlayer--flow' : null
|
||||
)}
|
||||
>
|
||||
<PlaybackButton
|
||||
context="incoming"
|
||||
variant="mini"
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { SmartMiniPlayer } from '../../state/smart/MiniPlayer';
|
||||
|
||||
export type PropsType = {
|
||||
conversationId: string;
|
||||
|
@ -87,7 +86,6 @@ export function ConversationView({
|
|||
{renderConversationHeader()}
|
||||
</div>
|
||||
<div className="ConversationView__pane main panel">
|
||||
<SmartMiniPlayer />
|
||||
<div className="ConversationView__timeline--container">
|
||||
<div aria-live="polite" className="ConversationView__timeline">
|
||||
{renderTimeline()}
|
||||
|
|
|
@ -335,9 +335,6 @@ const renderItem = ({
|
|||
getPreferredBadge={() => undefined}
|
||||
id=""
|
||||
isSelected={false}
|
||||
renderEmojiPicker={() => <div />}
|
||||
renderReactionPicker={() => <div />}
|
||||
item={items[messageId]}
|
||||
i18n={i18n}
|
||||
interactionMode="keyboard"
|
||||
isNextItemCallingNotification={false}
|
||||
|
@ -345,11 +342,14 @@ const renderItem = ({
|
|||
containerElementRef={containerElementRef}
|
||||
containerWidthBreakpoint={containerWidthBreakpoint}
|
||||
conversationId=""
|
||||
item={items[messageId]}
|
||||
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
|
||||
renderContact={() => '*ContactName*'}
|
||||
renderEmojiPicker={() => <div />}
|
||||
renderReactionPicker={() => <div />}
|
||||
renderUniversalTimerNotification={() => (
|
||||
<div>*UniversalTimerNotification*</div>
|
||||
)}
|
||||
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
|
||||
shouldCollapseAbove={false}
|
||||
shouldCollapseBelow={false}
|
||||
shouldHideMetadata={false}
|
||||
|
@ -436,6 +436,9 @@ const renderTypingBubble = () => (
|
|||
sharedGroupNames={[]}
|
||||
/>
|
||||
);
|
||||
const renderMiniPlayer = () => (
|
||||
<div>If active, this is where smart mini player would be</div>
|
||||
);
|
||||
|
||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
discardMessages: action('discardMessages'),
|
||||
|
@ -455,6 +458,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
messageChangeCounter: 0,
|
||||
scrollToIndex: overrideProps.scrollToIndex,
|
||||
scrollToIndexCounter: 0,
|
||||
shouldShowMiniPlayer: Boolean(overrideProps.shouldShowMiniPlayer),
|
||||
totalUnseen: number('totalUnseen', overrideProps.totalUnseen || 0),
|
||||
oldestUnseenIndex:
|
||||
number('oldestUnseenIndex', overrideProps.oldestUnseenIndex || 0) ||
|
||||
|
@ -466,6 +470,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
id: uuid(),
|
||||
renderItem,
|
||||
renderHeroRow,
|
||||
renderMiniPlayer,
|
||||
renderTypingBubble,
|
||||
renderContactSpoofingReviewDialog,
|
||||
isSomeoneTyping: overrideProps.isSomeoneTyping || false,
|
||||
|
@ -620,3 +625,12 @@ export function WithSameNameInGroupConversationWarning(): JSX.Element {
|
|||
WithSameNameInGroupConversationWarning.story = {
|
||||
name: 'With "same name in group conversation" warning',
|
||||
};
|
||||
|
||||
export function WithJustMiniPlayer(): JSX.Element {
|
||||
const props = useProps({
|
||||
shouldShowMiniPlayer: true,
|
||||
items: [],
|
||||
});
|
||||
|
||||
return <Timeline {...props} />;
|
||||
}
|
||||
|
|
|
@ -100,8 +100,9 @@ type PropsHousekeepingType = {
|
|||
isSomeoneTyping: boolean;
|
||||
unreadCount?: number;
|
||||
|
||||
selectedMessageId?: string;
|
||||
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
||||
selectedMessageId?: string;
|
||||
shouldShowMiniPlayer: boolean;
|
||||
|
||||
warning?: WarningType;
|
||||
contactSpoofingReview?: ContactSpoofingReviewPropType;
|
||||
|
@ -120,6 +121,10 @@ type PropsHousekeepingType = {
|
|||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
|
||||
renderContactSpoofingReviewDialog: (
|
||||
props: SmartContactSpoofingReviewDialogPropsType
|
||||
) => JSX.Element;
|
||||
renderHeroRow: (id: string) => JSX.Element;
|
||||
renderItem: (props: {
|
||||
containerElementRef: RefObject<HTMLElement>;
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
|
@ -130,11 +135,8 @@ type PropsHousekeepingType = {
|
|||
previousMessageId: undefined | string;
|
||||
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||
}) => JSX.Element;
|
||||
renderHeroRow: (id: string) => JSX.Element;
|
||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||
renderTypingBubble: (id: string) => JSX.Element;
|
||||
renderContactSpoofingReviewDialog: (
|
||||
props: SmartContactSpoofingReviewDialogPropsType
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
export type PropsActionsType = {
|
||||
|
@ -758,9 +760,11 @@ export class Timeline extends React.Component<
|
|||
renderContactSpoofingReviewDialog,
|
||||
renderHeroRow,
|
||||
renderItem,
|
||||
renderMiniPlayer,
|
||||
renderTypingBubble,
|
||||
reviewGroupMemberNameCollision,
|
||||
reviewMessageRequestNameCollision,
|
||||
shouldShowMiniPlayer,
|
||||
theme,
|
||||
totalUnseen,
|
||||
unreadCount,
|
||||
|
@ -890,72 +894,74 @@ export class Timeline extends React.Component<
|
|||
}
|
||||
|
||||
const warning = Timeline.getWarning(this.props, this.state);
|
||||
let timelineWarning: ReactNode;
|
||||
if (warning) {
|
||||
let text: ReactChild;
|
||||
let headerElements: ReactNode;
|
||||
if (warning || shouldShowMiniPlayer) {
|
||||
let text: ReactChild | undefined;
|
||||
let onClose: () => void;
|
||||
switch (warning.type) {
|
||||
case ContactSpoofingType.DirectConversationWithSameTitle:
|
||||
text = (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="ContactSpoofing__same-name"
|
||||
components={{
|
||||
link: (
|
||||
<TimelineWarning.Link
|
||||
onClick={() => {
|
||||
reviewMessageRequestNameCollision({
|
||||
safeConversationId: warning.safeConversation.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{i18n('ContactSpoofing__same-name__link')}
|
||||
</TimelineWarning.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
onClose = () => {
|
||||
this.setState({
|
||||
hasDismissedDirectContactSpoofingWarning: true,
|
||||
});
|
||||
};
|
||||
break;
|
||||
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
|
||||
const { groupNameCollisions } = warning;
|
||||
text = (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="ContactSpoofing__same-name-in-group"
|
||||
components={{
|
||||
count: Object.values(groupNameCollisions)
|
||||
.reduce(
|
||||
(result, conversations) => result + conversations.length,
|
||||
0
|
||||
)
|
||||
.toString(),
|
||||
link: (
|
||||
<TimelineWarning.Link
|
||||
onClick={() => {
|
||||
reviewGroupMemberNameCollision(id);
|
||||
}}
|
||||
>
|
||||
{i18n('ContactSpoofing__same-name-in-group__link')}
|
||||
</TimelineWarning.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
onClose = () => {
|
||||
acknowledgeGroupMemberNameCollisions(id, groupNameCollisions);
|
||||
};
|
||||
break;
|
||||
if (warning) {
|
||||
switch (warning.type) {
|
||||
case ContactSpoofingType.DirectConversationWithSameTitle:
|
||||
text = (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="ContactSpoofing__same-name"
|
||||
components={{
|
||||
link: (
|
||||
<TimelineWarning.Link
|
||||
onClick={() => {
|
||||
reviewMessageRequestNameCollision({
|
||||
safeConversationId: warning.safeConversation.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{i18n('ContactSpoofing__same-name__link')}
|
||||
</TimelineWarning.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
onClose = () => {
|
||||
this.setState({
|
||||
hasDismissedDirectContactSpoofingWarning: true,
|
||||
});
|
||||
};
|
||||
break;
|
||||
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
|
||||
const { groupNameCollisions } = warning;
|
||||
text = (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="ContactSpoofing__same-name-in-group"
|
||||
components={{
|
||||
count: Object.values(groupNameCollisions)
|
||||
.reduce(
|
||||
(result, conversations) => result + conversations.length,
|
||||
0
|
||||
)
|
||||
.toString(),
|
||||
link: (
|
||||
<TimelineWarning.Link
|
||||
onClick={() => {
|
||||
reviewGroupMemberNameCollision(id);
|
||||
}}
|
||||
>
|
||||
{i18n('ContactSpoofing__same-name-in-group__link')}
|
||||
</TimelineWarning.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
onClose = () => {
|
||||
acknowledgeGroupMemberNameCollisions(id, groupNameCollisions);
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(warning);
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(warning);
|
||||
}
|
||||
|
||||
timelineWarning = (
|
||||
headerElements = (
|
||||
<Measure
|
||||
bounds
|
||||
onResize={({ bounds }) => {
|
||||
|
@ -968,12 +974,15 @@ export class Timeline extends React.Component<
|
|||
>
|
||||
{({ measureRef }) => (
|
||||
<TimelineWarnings ref={measureRef}>
|
||||
<TimelineWarning i18n={i18n} onClose={onClose}>
|
||||
<TimelineWarning.IconContainer>
|
||||
<TimelineWarning.GenericIcon />
|
||||
</TimelineWarning.IconContainer>
|
||||
<TimelineWarning.Text>{text}</TimelineWarning.Text>
|
||||
</TimelineWarning>
|
||||
{renderMiniPlayer({ shouldFlow: true })}
|
||||
{text && (
|
||||
<TimelineWarning i18n={i18n} onClose={onClose}>
|
||||
<TimelineWarning.IconContainer>
|
||||
<TimelineWarning.GenericIcon />
|
||||
</TimelineWarning.IconContainer>
|
||||
<TimelineWarning.Text>{text}</TimelineWarning.Text>
|
||||
</TimelineWarning>
|
||||
)}
|
||||
</TimelineWarnings>
|
||||
)}
|
||||
</Measure>
|
||||
|
@ -1044,7 +1053,7 @@ export class Timeline extends React.Component<
|
|||
onKeyDown={this.handleKeyDown}
|
||||
ref={measureRef}
|
||||
>
|
||||
{timelineWarning}
|
||||
{headerElements}
|
||||
|
||||
{floatingHeader}
|
||||
|
||||
|
|
|
@ -190,6 +190,7 @@ const {
|
|||
} = window.Signal.Data;
|
||||
|
||||
const FIVE_MINUTES = MINUTE * 5;
|
||||
const FETCH_TIMEOUT = SECOND * 30;
|
||||
|
||||
const JOB_REPORTING_THRESHOLD_MS = 25;
|
||||
const SEND_REPORTING_THRESHOLD_MS = 25;
|
||||
|
@ -1366,10 +1367,12 @@ export class ConversationModel extends window.Backbone
|
|||
e164,
|
||||
reason: 'ConversationModel.onNewMessage',
|
||||
});
|
||||
const typingToken = `${source?.id}.${sourceDevice}`;
|
||||
if (source) {
|
||||
const typingToken = `${source.id}.${sourceDevice}`;
|
||||
|
||||
// Clear typing indicator for a given contact if we receive a message from them
|
||||
this.clearContactTypingTimer(typingToken);
|
||||
// Clear typing indicator for a given contact if we receive a message from them
|
||||
this.clearContactTypingTimer(typingToken);
|
||||
}
|
||||
|
||||
// If it's a group story reply or a story message, we don't want to update
|
||||
// the last message or add new messages to redux.
|
||||
|
@ -1451,10 +1454,18 @@ export class ConversationModel extends window.Backbone
|
|||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
const finish = () => {
|
||||
resolvePromise();
|
||||
clearTimeout(timeout);
|
||||
this.inProgressFetch = undefined;
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
log.warn(
|
||||
`setInProgressFetch(${this.idForLogging()}): Calling finish manually after timeout`
|
||||
);
|
||||
finish();
|
||||
}, FETCH_TIMEOUT);
|
||||
|
||||
return finish;
|
||||
}
|
||||
|
@ -5542,7 +5553,7 @@ export class ConversationModel extends window.Backbone
|
|||
return;
|
||||
}
|
||||
|
||||
const typingToken = `${senderId}.${senderDevice}`;
|
||||
const typingToken = `${sender.id}.${senderDevice}`;
|
||||
|
||||
this.contactTypingTimers = this.contactTypingTimers || {};
|
||||
const record = this.contactTypingTimers[typingToken];
|
||||
|
|
|
@ -14,6 +14,7 @@ import { SmartLeftPane } from './LeftPane';
|
|||
import { useConversationsActions } from '../ducks/conversations';
|
||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
||||
import { SmartMiniPlayer } from './MiniPlayer';
|
||||
|
||||
function renderConversationView() {
|
||||
return <SmartConversationView />;
|
||||
|
@ -23,6 +24,10 @@ function renderCustomizingPreferredReactionsModal() {
|
|||
return <SmartCustomizingPreferredReactionsModal />;
|
||||
}
|
||||
|
||||
function renderMiniPlayer(options: { shouldFlow: boolean }) {
|
||||
return <SmartMiniPlayer {...options} />;
|
||||
}
|
||||
|
||||
function renderLeftPane() {
|
||||
return <SmartLeftPane />;
|
||||
}
|
||||
|
@ -59,6 +64,7 @@ export function SmartInbox(): JSX.Element {
|
|||
renderCustomizingPreferredReactionsModal
|
||||
}
|
||||
renderLeftPane={renderLeftPane}
|
||||
renderMiniPlayer={renderMiniPlayer}
|
||||
scrollToMessage={scrollToMessage}
|
||||
selectedConversationId={selectedConversationId}
|
||||
selectedMessage={selectedMessage}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MiniPlayer, PlayerState } from '../../components/MiniPlayer';
|
||||
import type { Props as DumbProps } from '../../components/MiniPlayer';
|
||||
import {
|
||||
AudioPlayerContent,
|
||||
useAudioPlayerActions,
|
||||
|
@ -14,13 +15,15 @@ import {
|
|||
} from '../selectors/audioPlayer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
||||
type Props = Pick<DumbProps, 'shouldFlow'>;
|
||||
|
||||
/**
|
||||
* Wires the dispatch props and shows/hides the MiniPlayer
|
||||
*
|
||||
* It also triggers side-effecting actions (actual playback) in response to changes in
|
||||
* the state
|
||||
*/
|
||||
export function SmartMiniPlayer(): JSX.Element | null {
|
||||
export function SmartMiniPlayer({ shouldFlow }: Props): JSX.Element | null {
|
||||
const i18n = useSelector(getIntl);
|
||||
const active = useSelector(selectAudioPlayerActive);
|
||||
const getVoiceNoteTitle = useSelector(selectVoiceNoteTitle);
|
||||
|
@ -56,6 +59,7 @@ export function SmartMiniPlayer(): JSX.Element | null {
|
|||
onPause={handlePause}
|
||||
onPlaybackRate={setPlaybackRate}
|
||||
onClose={unloadMessageAudio}
|
||||
shouldFlow={shouldFlow}
|
||||
state={state}
|
||||
currentTime={active.currentTime}
|
||||
duration={active.duration}
|
||||
|
|
|
@ -26,6 +26,7 @@ import {
|
|||
getInvitedContactsForNewlyCreatedGroup,
|
||||
getSelectedMessage,
|
||||
} from '../selectors/conversations';
|
||||
import { selectAudioPlayerActive } from '../selectors/audioPlayer';
|
||||
|
||||
import { SmartTimelineItem } from './TimelineItem';
|
||||
import { SmartContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
|
||||
|
@ -46,6 +47,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
|
|||
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
|
||||
import type { WidthBreakpoint } from '../../components/_util';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { SmartMiniPlayer } from './MiniPlayer';
|
||||
|
||||
type ExternalProps = {
|
||||
id: string;
|
||||
|
@ -93,6 +95,9 @@ function renderContactSpoofingReviewDialog(
|
|||
function renderHeroRow(id: string): JSX.Element {
|
||||
return <SmartHeroRow id={id} />;
|
||||
}
|
||||
function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element {
|
||||
return <SmartMiniPlayer {...options} />;
|
||||
}
|
||||
function renderTypingBubble(id: string): JSX.Element {
|
||||
return <SmartTypingBubble id={id} />;
|
||||
}
|
||||
|
@ -227,6 +232,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
const getTimestampForMessage = (messageId: string): undefined | number =>
|
||||
getMessages(state)[messageId]?.timestamp;
|
||||
|
||||
const shouldShowMiniPlayer = Boolean(selectAudioPlayerActive(state));
|
||||
|
||||
return {
|
||||
id,
|
||||
...pick(conversation, ['unreadCount', 'isGroupV1AndDisabled']),
|
||||
|
@ -237,9 +244,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
),
|
||||
isSomeoneTyping: Boolean(conversation.typingContactId),
|
||||
...conversationMessages,
|
||||
|
||||
invitedContactsForNewlyCreatedGroup:
|
||||
getInvitedContactsForNewlyCreatedGroup(state),
|
||||
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
|
||||
shouldShowMiniPlayer,
|
||||
|
||||
warning: getWarning(conversation, state),
|
||||
contactSpoofingReview: getContactSpoofingReview(id, state),
|
||||
|
@ -248,9 +257,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||
i18n: getIntl(state),
|
||||
theme: getTheme(state),
|
||||
renderItem,
|
||||
|
||||
renderContactSpoofingReviewDialog,
|
||||
renderHeroRow,
|
||||
renderItem,
|
||||
renderMiniPlayer,
|
||||
renderTypingBubble,
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue