diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 3e9f8e117..9d6f2c323 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -162,6 +162,7 @@ const MessageAudioContainer: React.FC = ({ if (!playing) { audio.play(); setPlaying(true); + setPlayed(true); } if (!Number.isNaN(audio.duration)) { @@ -195,10 +196,6 @@ const MessageAudioContainer: React.FC = ({ ? { playing, playbackRate, currentTime, duration: audio.duration } : undefined; - const setPlayedAction = () => { - setPlayed(true); - }; - return ( = ({ active={active} played={_played} loadAndPlayMessageAudio={loadAndPlayMessageAudio} - onFirstPlayed={setPlayedAction} setIsPlaying={setIsPlayingAction} setPlaybackRate={setPlaybackRateAction} setCurrentTime={setCurrentTimeAction} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index f53c04747..99ecc6bba 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -18,7 +18,7 @@ import type { } from '../../state/ducks/conversations'; import type { ViewStoryActionCreatorType } from '../../state/ducks/stories'; import type { TimelineItemType } from './TimelineItem'; -import { ReadStatus } from '../../messages/MessageReadStatus'; +import type { ReadStatus } from '../../messages/MessageReadStatus'; import { Avatar, AvatarSize } from '../Avatar'; import { AvatarSpacer } from '../AvatarSpacer'; import { Spinner } from '../Spinner'; @@ -61,6 +61,7 @@ import { isImageAttachment, isVideo, isGIF, + isPlayed, } from '../../types/Attachment'; import type { EmbeddedContactType } from '../../types/EmbeddedContact'; @@ -179,7 +180,6 @@ export type AudioAttachmentProps = { kickOffAttachmentDownload(): void; onCorrupted(): void; - onFirstPlayed(): void; }; export enum GiftBadgeStates { @@ -902,7 +902,6 @@ export class Message extends React.PureComponent { isSticker, kickOffAttachmentDownload, markAttachmentAsCorrupted, - markViewed, quote, readStatus, reducedMotion, @@ -1017,19 +1016,7 @@ export class Message extends React.PureComponent { } } if (isAudio(attachments)) { - let played: boolean; - switch (direction) { - case 'outgoing': - played = status === 'viewed'; - break; - case 'incoming': - played = readStatus === ReadStatus.Viewed; - break; - default: - log.error(missingCaseError(direction)); - played = false; - break; - } + const played = isPlayed(direction, status, readStatus); return renderAudioAttachment({ i18n, @@ -1064,9 +1051,6 @@ export class Message extends React.PureComponent { messageId: id, }); }, - onFirstPlayed() { - markViewed(id); - }, }); } const { pending, fileName, fileSize, contentType } = firstAttachment; diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index f46cabed3..81120d6e3 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -1,7 +1,7 @@ // Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useRef, useEffect, useState } from 'react'; +import React, { useCallback, useRef, useEffect, useState } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; import { animated, useSpring } from '@react-spring/web'; @@ -38,7 +38,6 @@ export type OwnProps = Readonly<{ timestamp: number; kickOffAttachmentDownload(): void; onCorrupted(): void; - onFirstPlayed(): void; computePeaks(url: string, barCount: number): Promise; }>; @@ -63,6 +62,7 @@ type ButtonProps = { mod?: string; label: string; visible?: boolean; + animateClick?: boolean; onClick: () => void; onMouseDown?: () => void; onMouseUp?: () => void; @@ -91,7 +91,7 @@ const BIG_INCREMENT = 5; const PLAYBACK_RATES = [1, 1.5, 2, 0.5]; -const SPRING_DEFAULTS = { +const SPRING_CONFIG = { mass: 0.5, tension: 350, friction: 20, @@ -131,33 +131,42 @@ const Button: React.FC = props => { children, onClick, visible = true, + animateClick = true, } = props; const [isDown, setIsDown] = useState(false); - const animProps = useSpring({ - ...SPRING_DEFAULTS, - from: isDown ? { scale: 1 } : { scale: 0 }, - to: isDown ? { scale: 1.3 } : { scale: visible ? 1 : 0 }, - }); + const [animProps] = useSpring( + { + config: SPRING_CONFIG, + to: isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 }, + }, + [visible, isDown, animateClick] + ); // Clicking button toggle playback - const onButtonClick = (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); + const onButtonClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); - onClick(); - }; + onClick(); + }, + [onClick] + ); // Keyboard playback toggle - const onButtonKeyDown = (event: React.KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== 'Space') { - return; - } - event.stopPropagation(); - event.preventDefault(); + const onButtonKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== 'Space') { + return; + } + event.stopPropagation(); + event.preventDefault(); - onClick(); - }; + onClick(); + }, + [onClick] + ); return ( @@ -193,7 +202,7 @@ const PlayedDot = ({ const [animProps] = useSpring( { - ...SPRING_DEFAULTS, + config: SPRING_CONFIG, from: { scale: start, opacity: start, width: start }, to: { scale: end, opacity: end, width: end * DOT_DIV_WIDTH }, onRest: () => { @@ -253,7 +262,6 @@ export const MessageAudio: React.FC = (props: Props) => { kickOffAttachmentDownload, onCorrupted, - onFirstPlayed, computePeaks, setPlaybackRate, loadAndPlayMessageAudio, @@ -366,12 +374,6 @@ export const MessageAudio: React.FC = (props: Props) => { } }; - useEffect(() => { - if (!played && isPlaying) { - onFirstPlayed(); - } - }, [played, isPlaying, onFirstPlayed]); - // Clicking waveform moves playback head position and starts playback. const onWaveformClick = (event: React.MouseEvent) => { event.preventDefault(); @@ -508,6 +510,7 @@ export const MessageAudio: React.FC = (props: Props) => { variant="play" mod="download" label="MessageAudio--download" + animateClick={false} onClick={kickOffAttachmentDownload} /> ); @@ -519,6 +522,7 @@ export const MessageAudio: React.FC = (props: Props) => { variant="play" mod={isPlaying ? 'pause' : 'play'} label={isPlaying ? 'MessageAudio--pause' : 'MessageAudio--play'} + animateClick={false} onClick={toggleIsPlaying} /> ); @@ -561,7 +565,7 @@ export const MessageAudio: React.FC = (props: Props) => { variant="playback-rate" i18n={i18n} label={(active && playbackRateLabels[active.playbackRate]) ?? ''} - visible={isPlaying && (!played || (played && !isPlayedDotVisible))} + visible={isPlaying && (!played || !isPlayedDotVisible)} onClick={() => { if (active) { setPlaybackRate( diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 2c30c488b..79aac7aa3 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -65,6 +65,7 @@ export type PropsData = { i18n: LocalizerType; theme: ThemeType; getPreferredBadge: PreferredBadgeSelectorType; + markViewed: (messageId: string) => void; } & Pick< MessagePropsType, | 'getPreferredBadge' @@ -78,7 +79,6 @@ export type PropsBackboneActions = Pick< | 'displayTapToViewMessage' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' - | 'markViewed' | 'openConversation' | 'openGiftBadge' | 'openLink' diff --git a/ts/state/ducks/audioPlayer.ts b/ts/state/ducks/audioPlayer.ts index aac63402d..fd9a3f2a7 100644 --- a/ts/state/ducks/audioPlayer.ts +++ b/ts/state/ducks/audioPlayer.ts @@ -22,11 +22,15 @@ import type { import { SELECTED_CONVERSATION_CHANGED, setVoiceNotePlaybackRate, + markViewed, } from './conversations'; import * as log from '../../logging/log'; import { strictAssert } from '../../util/assert'; import { globalMessageAudio } from '../../services/globalMessageAudio'; +import { isPlayed } from '../../types/Attachment'; +import { getMessageIdForLogging } from '../../util/idForLogging'; +import { getMessagePropStatus } from '../selectors/message'; // State @@ -254,6 +258,33 @@ function loadAndPlayMessageAudio( }, }); + // mark the message as played + const message = getState().conversations.messagesLookup[id]; + if (message) { + const messageIdForLogging = getMessageIdForLogging(message); + const status = getMessagePropStatus(message, message.conversationId); + + if (message.type === 'incoming' || message.type === 'outgoing') { + if (!isPlayed(message.type, status, message.readStatus)) { + markViewed(id); + } else { + log.info( + 'audioPlayer.loadAndPlayMessageAudio: message already played', + { message: messageIdForLogging } + ); + } + } else { + log.warn( + `audioPlayer.loadAndPlayMessageAudio: message wrong type: ${message.type}`, + { message: messageIdForLogging } + ); + } + } else { + log.warn('audioPlayer.loadAndPlayMessageAudio: message not found', { + message: id, + }); + } + // set the playback rate to the stored value for the selected conversation const conversationId = getSelectedConversationId(getState()); if (conversationId) { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index f5c1855d9..0030d419d 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -76,6 +76,7 @@ import { OneTimeModalState, UsernameSaveState, } from './conversationsEnums'; +import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater'; import { showToast } from '../../util/showToast'; import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername'; import { useBoundActions } from '../../hooks/useBoundActions'; @@ -83,8 +84,15 @@ import { useBoundActions } from '../../hooks/useBoundActions'; import type { NoopActionType } from './noop'; import { conversationJobQueue } from '../../jobs/conversationJobQueue'; import type { TimelineMessageLoadingState } from '../../util/timelineUtil'; -import { isGroup } from '../../util/whatTypeOfConversation'; +import { + isDirectConversation, + isGroup, +} from '../../util/whatTypeOfConversation'; import { missingCaseError } from '../../util/missingCaseError'; +import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; +import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; +import { ReadStatus } from '../../messages/MessageReadStatus'; +import { isIncoming } from '../selectors/message'; // State @@ -1003,6 +1011,56 @@ function generateNewGroupLink( }; } +/** + * Not an actual redux action creator, so it doesn't produce an action (or dispatch + * itself) because updates are managed through the backbone model, which will trigger + * necessary updates and refresh conversation_view. + * + * In practice, it's similar to an already-connected thunk action. Later on we will + * replace it with an actual action that fits in with the redux approach. + */ +export const markViewed = (messageId: string): void => { + const message = window.MessageController.getById(messageId); + if (!message) { + throw new Error(`markViewed: Message ${messageId} missing!`); + } + + if (message.get('readStatus') === ReadStatus.Viewed) { + return; + } + + const senderE164 = message.get('source'); + const senderUuid = message.get('sourceUuid'); + const timestamp = message.get('sent_at'); + + message.set(messageUpdaterMarkViewed(message.attributes, Date.now())); + + if (isIncoming(message.attributes)) { + viewedReceiptsJobQueue.add({ + viewedReceipt: { + messageId, + senderE164, + senderUuid, + timestamp, + isDirectConversation: isDirectConversation( + message.getConversation()?.attributes + ), + }, + }); + } + + viewSyncJobQueue.add({ + viewSyncs: [ + { + messageId, + senderE164, + senderUuid, + timestamp, + }, + ], + }); +}; + function setAccessControlAddFromInviteLinkSetting( conversationId: string, value: boolean diff --git a/ts/state/roots/createMessageDetail.tsx b/ts/state/roots/createMessageDetail.tsx index c934bee42..fc5cf7990 100644 --- a/ts/state/roots/createMessageDetail.tsx +++ b/ts/state/roots/createMessageDetail.tsx @@ -12,7 +12,7 @@ import { SmartMessageDetail } from '../smart/MessageDetail'; export const createMessageDetail = ( store: Store, - props: OwnProps + props: Omit ): ReactElement => ( diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx index 31f3d377d..66288325b 100644 --- a/ts/state/smart/MessageAudio.tsx +++ b/ts/state/smart/MessageAudio.tsx @@ -40,7 +40,6 @@ export type Props = { computePeaks(url: string, barCount: number): Promise; kickOffAttachmentDownload(): void; onCorrupted(): void; - onFirstPlayed(): void; }; const mapStateToProps = ( diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 9f9ae4de5..7ee90c674 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -14,6 +14,7 @@ import { renderAudioAttachment } from './renderAudioAttachment'; import { renderEmojiPicker } from './renderEmojiPicker'; import { renderReactionPicker } from './renderReactionPicker'; import { getContactNameColorSelector } from '../selectors/conversations'; +import { markViewed } from '../ducks/conversations'; export { Contact } from '../../components/conversation/MessageDetail'; export type OwnProps = Omit< @@ -25,6 +26,7 @@ export type OwnProps = Omit< | 'renderEmojiPicker' | 'renderReactionPicker' | 'theme' + | 'markViewed' >; const mapStateToProps = ( @@ -43,7 +45,6 @@ const mapStateToProps = ( displayTapToViewMessage, kickOffAttachmentDownload, markAttachmentAsCorrupted, - markViewed, openConversation, openGiftBadge, openLink, diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index be8170038..c14f917ab 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -50,6 +50,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing'; import type { UnreadIndicatorPlacement } from '../../util/timelineUtil'; import type { WidthBreakpoint } from '../../components/_util'; import { getPreferredBadgeSelector } from '../selectors/badges'; +import { markViewed } from '../ducks/conversations'; type ExternalProps = { id: string; @@ -76,7 +77,6 @@ export type TimelinePropsType = ExternalProps & | 'loadOlderMessages' | 'markAttachmentAsCorrupted' | 'markMessageRead' - | 'markViewed' | 'onBlock' | 'onBlockAndReportSpam' | 'onDelete' @@ -317,6 +317,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { renderContactSpoofingReviewDialog, renderHeroRow, renderTypingBubble, + markViewed, ...actions, }; }; diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 352397236..5b805f348 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -29,6 +29,8 @@ import * as GoogleChrome from '../util/GoogleChrome'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { getValue } from '../RemoteConfig'; import { isRecord } from '../util/isRecord'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import type { MessageStatusType } from '../components/conversation/Message'; const MAX_WIDTH = 300; const MAX_HEIGHT = MAX_WIDTH * 1.5; @@ -652,6 +654,17 @@ export function isAudio(attachments?: ReadonlyArray): boolean { ); } +export function isPlayed( + direction: 'outgoing' | 'incoming', + status: MessageStatusType | undefined, + readStatus: ReadStatus | undefined +): boolean { + if (direction === 'outgoing') { + return status === 'viewed'; + } + return readStatus === ReadStatus.Viewed; +} + export function canDisplayImage( attachments?: ReadonlyArray ): boolean { diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index ae07e9f9d..0fa4c7432 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -56,7 +56,6 @@ import type { EmbeddedContactType } from '../types/EmbeddedContact'; import { createConversationView } from '../state/roots/createConversationView'; import { AttachmentToastType } from '../types/AttachmentToastType'; import type { CompositionAPIType } from '../components/CompositionArea'; -import { ReadStatus } from '../messages/MessageReadStatus'; import { SignalService as Proto } from '../protobuf'; import { ToastBlocked } from '../components/ToastBlocked'; import { ToastBlockedGroup } from '../components/ToastBlockedGroup'; @@ -85,12 +84,9 @@ import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge import { deleteDraftAttachment } from '../util/deleteDraftAttachment'; import { retryMessageSend } from '../util/retryMessageSend'; import { isNotNil } from '../util/isNotNil'; -import { markViewed } from '../services/MessageUpdater'; import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser'; import { resolveAttachmentDraftData } from '../util/resolveAttachmentDraftData'; import { showToast } from '../util/showToast'; -import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue'; -import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue'; import { RecordingState } from '../state/ducks/audioRecorder'; import { UUIDKind } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID'; @@ -152,7 +148,6 @@ type MessageActionsType = { options: Readonly<{ messageId: string }> ) => unknown; markAttachmentAsCorrupted: (options: AttachmentOptions) => unknown; - markViewed: (messageId: string) => unknown; openConversation: (conversationId: string, messageId?: string) => unknown; openGiftBadge: (messageId: string) => unknown; openLink: (url: string) => unknown; @@ -793,45 +788,7 @@ export class ConversationView extends window.Backbone.View { } message.markAttachmentAsCorrupted(options.attachment); }; - const onMarkViewed = (messageId: string): void => { - const message = window.MessageController.getById(messageId); - if (!message) { - throw new Error(`onMarkViewed: Message ${messageId} missing!`); - } - if (message.get('readStatus') === ReadStatus.Viewed) { - return; - } - - const senderE164 = message.get('source'); - const senderUuid = message.get('sourceUuid'); - const timestamp = message.get('sent_at'); - - message.set(markViewed(message.attributes, Date.now())); - - if (isIncoming(message.attributes)) { - viewedReceiptsJobQueue.add({ - viewedReceipt: { - messageId, - senderE164, - senderUuid, - timestamp, - isDirectConversation: isDirectConversation(this.model.attributes), - }, - }); - } - - viewSyncJobQueue.add({ - viewSyncs: [ - { - messageId, - senderE164, - senderUuid, - timestamp, - }, - ], - }); - }; const showVisualAttachment = (options: { attachment: AttachmentType; messageId: string; @@ -889,7 +846,6 @@ export class ConversationView extends window.Backbone.View { downloadNewVersion, kickOffAttachmentDownload, markAttachmentAsCorrupted, - markViewed: onMarkViewed, openConversation, openGiftBadge, openLink,