diff --git a/ts/components/GlobalAudioContext.tsx b/ts/components/GlobalAudioContext.tsx index 610b250ec..85f51b0cc 100644 --- a/ts/components/GlobalAudioContext.tsx +++ b/ts/components/GlobalAudioContext.tsx @@ -175,7 +175,8 @@ const globalContents: Contents = { export const GlobalAudioContext = React.createContext(globalContents); export type GlobalAudioProps = { - conversationId: string; + conversationId: string | undefined; + isPaused: boolean; children?: React.ReactNode | React.ReactChildren; }; @@ -185,6 +186,7 @@ export type GlobalAudioProps = { */ export const GlobalAudioProvider: React.FC = ({ conversationId, + isPaused, children, }) => { // When moving between conversations - stop audio @@ -194,6 +196,13 @@ export const GlobalAudioProvider: React.FC = ({ }; }, [conversationId]); + // Pause when requested by parent + React.useEffect(() => { + if (isPaused) { + globalContents.audio.pause(); + } + }, [isPaused]); + return ( {children} diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 7c330acbe..5adc423f9 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -47,19 +47,22 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({ ); const MessageAudioContainer: React.FC = props => { - const [activeAudioID, setActiveAudioID] = React.useState( - undefined - ); + const [active, setActive] = React.useState<{ + id?: string; + context?: string; + }>({}); const audio = React.useMemo(() => new Audio(), []); return ( setActive({ id, context })} + activeAudioID={active.id} + activeAudioContext={active.context} /> ); }; @@ -101,6 +104,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ undefined, i18n, id: text('id', overrideProps.id || ''), + renderingContext: 'storybook', interactionMode: overrideProps.interactionMode || 'keyboard', isSticker: isBoolean(overrideProps.isSticker) ? overrideProps.isSticker diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 2826b1a9a..c417ca611 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -89,6 +89,7 @@ export type DirectionType = typeof Directions[number]; export type AudioAttachmentProps = { id: string; + renderingContext: string; i18n: LocalizerType; buttonRef: React.RefObject; direction: DirectionType; @@ -103,6 +104,7 @@ export type AudioAttachmentProps = { export type PropsData = { id: string; + renderingContext: string; contactNameColor?: ContactNameColorType; conversationColor: ConversationColorType; customColor?: CustomColorType; @@ -751,6 +753,7 @@ export class Message extends React.Component { direction, i18n, id, + renderingContext, kickOffAttachmentDownload, markAttachmentAsCorrupted, quote, @@ -849,6 +852,7 @@ export class Message extends React.Component { i18n, buttonRef: this.audioButtonRef, id, + renderingContext, direction, theme, attachment: firstAttachment, diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index 8b6793790..6f8f0774d 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -14,6 +14,7 @@ import { ComputePeaksResult } from '../GlobalAudioContext'; export type Props = { direction?: 'incoming' | 'outgoing'; id: string; + renderingContext: string; i18n: LocalizerType; attachment: AttachmentType; withContentAbove: boolean; @@ -28,7 +29,8 @@ export type Props = { computePeaks(url: string, barCount: number): Promise; activeAudioID: string | undefined; - setActiveAudioID: (id: string | undefined) => void; + activeAudioContext: string | undefined; + setActiveAudioID: (id: string | undefined, context: string) => void; }; type ButtonProps = { @@ -121,14 +123,19 @@ const Button: React.FC = props => { * toggle Play/Pause button. * * A global audio player is used for playback and access is managed by the - * `activeAudioID` property. Whenever `activeAudioID` property is equal to `id` - * the instance of the `MessageAudio` assumes the ownership of the `Audio` - * instance and fully manages it. + * `activeAudioID` and `activeAudioContext` properties. Whenever both + * `activeAudioID` and `activeAudioContext` are equal to `id` and `context` + * respectively the instance of the `MessageAudio` assumes the ownership of the + * `Audio` instance and fully manages it. + * + * `context` is required for displaying separate MessageAudio instances in + * MessageDetails and Message React components. */ export const MessageAudio: React.FC = (props: Props) => { const { i18n, id, + renderingContext, direction, attachment, withContentAbove, @@ -142,12 +149,14 @@ export const MessageAudio: React.FC = (props: Props) => { computePeaks, activeAudioID, + activeAudioContext, setActiveAudioID, } = props; assert(audio !== null, 'GlobalAudioContext always provides audio'); - const isActive = activeAudioID === id; + const isActive = + activeAudioID === id && activeAudioContext === renderingContext; const waveformRef = useRef(null); const [isPlaying, setIsPlaying] = useState(isActive && !audio.paused); @@ -317,7 +326,7 @@ export const MessageAudio: React.FC = (props: Props) => { if (!isActive && !isPlaying) { window.log.info('MessageAudio: changing owner', id); - setActiveAudioID(id); + setActiveAudioID(id, renderingContext); // Pause old audio if (!audio.paused) { diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index e4b22bfa9..38b57a218 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -30,6 +30,7 @@ const defaultMessage: MessageDataPropsType = { conversationType: 'direct', direction: 'incoming', id: 'my-message', + renderingContext: 'storybook', isBlocked: false, isMessageRequestAccepted: true, previews: [], diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 10755c8ca..d0c465ef4 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -6,7 +6,6 @@ import classNames from 'classnames'; import moment from 'moment'; import { noop } from 'lodash'; -import { GlobalAudioProvider } from '../GlobalAudioContext'; import { Avatar } from '../Avatar'; import { ContactName } from './ContactName'; import { @@ -46,7 +45,7 @@ export type Props = { contacts: Array; contactNameColor?: ContactNameColorType; errors: Array; - message: MessagePropsDataType; + message: Omit; receivedAt: number; sentAt: number; @@ -266,57 +265,54 @@ export class MessageDetail extends React.Component { // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
- - { - assert( - false, - 'scrollToQuotedMessage should never be called because scrolling is disabled' - ); - }} - showContactDetail={showContactDetail} - showContactModal={showContactModal} - showExpiredIncomingTapToViewToast={ - showExpiredIncomingTapToViewToast - } - showExpiredOutgoingTapToViewToast={ - showExpiredOutgoingTapToViewToast - } - showMessageDetail={() => { - assert( - false, - "showMessageDetail should never be called because the menu is disabled (and we're already in the message detail!)" - ); - }} - showVisualAttachment={showVisualAttachment} - /> - + { + assert( + false, + 'scrollToQuotedMessage should never be called because scrolling is disabled' + ); + }} + showContactDetail={showContactDetail} + showContactModal={showContactModal} + showExpiredIncomingTapToViewToast={ + showExpiredIncomingTapToViewToast + } + showExpiredOutgoingTapToViewToast={ + showExpiredOutgoingTapToViewToast + } + showMessageDetail={() => { + assert( + false, + "showMessageDetail should never be called because the menu is disabled (and we're already in the message detail!)" + ); + }} + showVisualAttachment={showVisualAttachment} + />
diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 34f14ffea..95984b5ee 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -50,6 +50,7 @@ const defaultMessageProps: MessagesProps = { ), i18n, id: 'messageId', + renderingContext: 'storybook', interactionMode: 'keyboard', isBlocked: false, isMessageRequestAccepted: true, diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index dbe598148..9ff5acdc0 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -15,8 +15,6 @@ import Measure from 'react-measure'; import { ScrollDownButton } from './ScrollDownButton'; -import { GlobalAudioProvider } from '../GlobalAudioContext'; - import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; import { assert } from '../../util/assert'; @@ -1424,9 +1422,8 @@ export class Timeline extends React.PureComponent { > {timelineWarning} - - {autoSizer} - + {autoSizer} + {shouldShowScrollDownButton ? ( ; }; type UnsupportedMessageType = { type: 'unsupportedMessage'; @@ -189,7 +189,13 @@ export class TimelineItem extends React.PureComponent { if (item.type === 'message') { return ( - + ); } diff --git a/ts/state/ducks/audioPlayer.ts b/ts/state/ducks/audioPlayer.ts index 7364feb8f..f8ff804be 100644 --- a/ts/state/ducks/audioPlayer.ts +++ b/ts/state/ducks/audioPlayer.ts @@ -3,12 +3,17 @@ import { useBoundActions } from '../../util/hooks'; -import { SwitchToAssociatedViewActionType } from './conversations'; +import { + SwitchToAssociatedViewActionType, + MessageDeletedActionType, + MessageChangedActionType, +} from './conversations'; // State export type AudioPlayerStateType = { readonly activeAudioID: string | undefined; + readonly activeAudioContext: string | undefined; }; // Actions @@ -17,6 +22,7 @@ type SetActiveAudioIDAction = { type: 'audioPlayer/SET_ACTIVE_AUDIO_ID'; payload: { id: string | undefined; + context: string | undefined; }; }; @@ -30,10 +36,13 @@ export const actions = { export const useActions = (): typeof actions => useBoundActions(actions); -function setActiveAudioID(id: string | undefined): SetActiveAudioIDAction { +function setActiveAudioID( + id: string | undefined, + context: string +): SetActiveAudioIDAction { return { type: 'audioPlayer/SET_ACTIVE_AUDIO_ID', - payload: { id }, + payload: { id, context }, }; } @@ -42,12 +51,18 @@ function setActiveAudioID(id: string | undefined): SetActiveAudioIDAction { function getEmptyState(): AudioPlayerStateType { return { activeAudioID: undefined, + activeAudioContext: undefined, }; } export function reducer( state: Readonly = getEmptyState(), - action: Readonly + action: Readonly< + | AudioPlayerActionType + | SwitchToAssociatedViewActionType + | MessageDeletedActionType + | MessageChangedActionType + > ): AudioPlayerStateType { if (action.type === 'audioPlayer/SET_ACTIVE_AUDIO_ID') { const { payload } = action; @@ -55,6 +70,7 @@ export function reducer( return { ...state, activeAudioID: payload.id, + activeAudioContext: payload.context, }; } @@ -66,5 +82,36 @@ export function reducer( }; } + // Reset activeAudioID on when played message is deleted on expiration. + if (action.type === 'MESSAGE_DELETED') { + const { id } = action.payload; + if (state.activeAudioID !== id) { + return state; + } + + return { + ...state, + activeAudioID: undefined, + }; + } + + // Reset activeAudioID on when played message is deleted for everyone. + if (action.type === 'MESSAGE_CHANGED') { + const { id, data } = action.payload; + + if (state.activeAudioID !== id) { + return state; + } + + if (!data.deletedForEveryone) { + return state; + } + + return { + ...state, + activeAudioID: undefined, + }; + } + return state; } diff --git a/ts/state/roots/createApp.tsx b/ts/state/roots/createApp.tsx index 78c6b6c81..0f9b4f527 100644 --- a/ts/state/roots/createApp.tsx +++ b/ts/state/roots/createApp.tsx @@ -7,9 +7,12 @@ import { Provider } from 'react-redux'; import { Store } from 'redux'; import { SmartApp } from '../smart/App'; +import { SmartGlobalAudioProvider } from '../smart/GlobalAudioProvider'; export const createApp = (store: Store): ReactElement => ( - + + + ); diff --git a/ts/state/selectors/audioPlayer.ts b/ts/state/selectors/audioPlayer.ts new file mode 100644 index 000000000..bf284e133 --- /dev/null +++ b/ts/state/selectors/audioPlayer.ts @@ -0,0 +1,8 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { StateType } from '../reducer'; + +export const isPaused = (state: StateType): boolean => { + return state.audioPlayer.activeAudioID === undefined; +}; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 2cbe4ab49..10300b701 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -309,7 +309,7 @@ export function getPropsForMessage( readReceiptSetting: boolean, regionCode: string, accountSelector: (identifier?: string) => boolean -): PropsForMessage { +): Omit { const contact = getContact( message, conversationSelector, diff --git a/ts/state/smart/GlobalAudioProvider.tsx b/ts/state/smart/GlobalAudioProvider.tsx new file mode 100644 index 000000000..f31fc4d57 --- /dev/null +++ b/ts/state/smart/GlobalAudioProvider.tsx @@ -0,0 +1,20 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { GlobalAudioProvider } from '../../components/GlobalAudioContext'; +import { StateType } from '../reducer'; +import { isPaused } from '../selectors/audioPlayer'; +import { getSelectedConversationId } from '../selectors/conversations'; + +const mapStateToProps = (state: StateType) => { + return { + conversationId: getSelectedConversationId(state), + isPaused: isPaused(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartGlobalAudioProvider = smart(GlobalAudioProvider); diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx index 3ce9c192b..128a1cbd3 100644 --- a/ts/state/smart/MessageAudio.tsx +++ b/ts/state/smart/MessageAudio.tsx @@ -16,6 +16,7 @@ export type Props = { direction?: 'incoming' | 'outgoing'; id: string; + renderingContext: string; i18n: LocalizerType; attachment: AttachmentType; withContentAbove: boolean; diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 0ffd5cae3..d7429209d 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -24,7 +24,7 @@ export { Contact } from '../../components/conversation/MessageDetail'; export type OwnProps = { contacts: Array; errors: Array; - message: MessagePropsDataType; + message: Omit; receivedAt: number; sentAt: number; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index fd3e5058c..ddd680583 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -77,6 +77,7 @@ function renderItem( function renderLastSeenIndicator(id: string): JSX.Element { return ; } + function renderHeroRow( id: string, onHeightChange: () => unknown, diff --git a/ts/test-both/state/ducks/audioPlayer_test.ts b/ts/test-both/state/ducks/audioPlayer_test.ts index da0108a24..3fca5d6c2 100644 --- a/ts/test-both/state/ducks/audioPlayer_test.ts +++ b/ts/test-both/state/ducks/audioPlayer_test.ts @@ -4,22 +4,93 @@ import { assert } from 'chai'; import { actions } from '../../../state/ducks/audioPlayer'; +import { + actions as conversationsActions, + SwitchToAssociatedViewActionType, +} from '../../../state/ducks/conversations'; import { noopAction } from '../../../state/ducks/noop'; import { StateType, reducer as rootReducer } from '../../../state/reducer'; +const { messageDeleted, messageChanged } = conversationsActions; + +const MESSAGE_ID = 'message-id'; + describe('both/state/ducks/audioPlayer', () => { const getEmptyRootState = (): StateType => { return rootReducer(undefined, noopAction()); }; + const getInitializedState = (): StateType => { + const state = getEmptyRootState(); + + const updated = rootReducer( + state, + actions.setActiveAudioID(MESSAGE_ID, 'context') + ); + + assert.strictEqual(updated.audioPlayer.activeAudioID, MESSAGE_ID); + assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context'); + + return updated; + }; + describe('setActiveAudioID', () => { it("updates `activeAudioID` in the audioPlayer's state", () => { const state = getEmptyRootState(); assert.strictEqual(state.audioPlayer.activeAudioID, undefined); - const updated = rootReducer(state, actions.setActiveAudioID('test')); + const updated = rootReducer( + state, + actions.setActiveAudioID('test', 'context') + ); assert.strictEqual(updated.audioPlayer.activeAudioID, 'test'); + assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context'); }); }); + + it('resets activeAudioID when changing the conversation', () => { + const state = getInitializedState(); + + const updated = rootReducer(state, { + type: 'SWITCH_TO_ASSOCIATED_VIEW', + payload: { conversationId: 'any' }, + }); + + assert.strictEqual(updated.audioPlayer.activeAudioID, undefined); + assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context'); + }); + + it('resets activeAudioID when message was deleted', () => { + const state = getInitializedState(); + + const updated = rootReducer( + state, + messageDeleted(MESSAGE_ID, 'conversation-id') + ); + + assert.strictEqual(updated.audioPlayer.activeAudioID, undefined); + assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context'); + }); + + it('resets activeAudioID when message was erased', () => { + const state = getInitializedState(); + + const updated = rootReducer( + state, + messageChanged(MESSAGE_ID, 'conversation-id', { + id: MESSAGE_ID, + type: 'incoming', + sent_at: 1, + received_at: 1, + timestamp: 1, + conversationId: 'conversation-id', + + deletedForEveryone: true, + }) + ); + + assert.strictEqual(updated.audioPlayer.activeAudioID, undefined); + assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context'); + }); }); diff --git a/ts/test-electron/state/selectors/audioPlayer_test.ts b/ts/test-electron/state/selectors/audioPlayer_test.ts new file mode 100644 index 000000000..6a535cbda --- /dev/null +++ b/ts/test-electron/state/selectors/audioPlayer_test.ts @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { actions } from '../../../state/ducks/audioPlayer'; +import { noopAction } from '../../../state/ducks/noop'; +import { isPaused } from '../../../state/selectors/audioPlayer'; +import { StateType, reducer as rootReducer } from '../../../state/reducer'; + +describe('state/selectors/audioPlayer', () => { + const getEmptyRootState = (): StateType => { + return rootReducer(undefined, noopAction()); + }; + + describe('isPaused', () => { + it('returns true if state.audioPlayer.activeAudioID is undefined', () => { + const state = getEmptyRootState(); + assert.isTrue(isPaused(state)); + }); + + it('returns false if state.audioPlayer.activeAudioID is not undefined', () => { + const state = getEmptyRootState(); + + const updated = rootReducer( + state, + actions.setActiveAudioID('id', 'context') + ); + + assert.isFalse(isPaused(updated)); + }); + }); +});