diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f11bd65ed..f24ad3499 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -408,6 +408,8 @@ "linkVisitWarningTitle": "Open this link in your browser?", "linkVisitWarningMessage": "Are you sure you want to open $url$ in your browser?", "open": "Open", + "audioMessageAutoplayTitle": "Audio Message Autoplay", + "audioMessageAutoplayDescription": "Automatically play consecutively sent audio messages", "clickToTrustContact": "Click to download media", "trustThisContactDialogTitle": "Trust $name$?", "trustThisContactDialogDescription": "Are you sure you want to download media sent by $name$?" diff --git a/package.json b/package.json index 258ec57cd..72a9eb215 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "read-last-lines": "1.3.0", "redux": "4.0.1", "redux-logger": "3.0.6", + "redux-persist": "^6.0.0", "redux-promise-middleware": "6.1.0", "reselect": "4.0.0", "rimraf": "2.6.2", diff --git a/ts/components/conversation/H5AudioPlayer.tsx b/ts/components/conversation/H5AudioPlayer.tsx index 04d4187f2..53c3bf8ed 100644 --- a/ts/components/conversation/H5AudioPlayer.tsx +++ b/ts/components/conversation/H5AudioPlayer.tsx @@ -1,14 +1,19 @@ // Audio Player -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import H5AudioPlayer from 'react-h5-audio-player'; +import { useSelector } from 'react-redux'; import { useTheme } from 'styled-components'; import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; +import { getAudioAutoplay } from '../../state/selectors/userConfig'; import { SessionIcon, SessionIconSize, SessionIconType } from '../session/icon'; export const AudioPlayerWithEncryptedFile = (props: { src: string; contentType: string; playbackSpeed: number; + playNextMessage?: (index: number) => void; + playableMessageIndex?: number; + nextMessageToPlay?: number; }) => { const theme = useTheme(); const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType); @@ -22,6 +27,24 @@ export const AudioPlayerWithEncryptedFile = (props: { } }, [playbackSpeed]); + useEffect(() => { + if (props.playableMessageIndex === props.nextMessageToPlay) { + player.current?.audio.current?.play(); + } + }); + + const onEnded = () => { + // if audio autoplay is enabled, call method to start playing + // the next playable message + if ( + window.inboxStore?.getState().userConfig.audioAutoplay === true && + props.playNextMessage && + props.playableMessageIndex !== undefined + ) { + props.playNextMessage(props.playableMessageIndex); + } + }; + return ( { playbackSpeed={this.state.playbackSpeed} src={firstAttachment.url} contentType={firstAttachment.contentType} + playNextMessage={this.props.playNextMessage} + playableMessageIndex={this.props.playableMessageIndex} + nextMessageToPlay={this.props.nextMessageToPlay} /> ); diff --git a/ts/components/session/SessionInboxView.tsx b/ts/components/session/SessionInboxView.tsx index 08aba3518..900fb9453 100644 --- a/ts/components/session/SessionInboxView.tsx +++ b/ts/components/session/SessionInboxView.tsx @@ -13,11 +13,16 @@ import { initialOnionPathState } from '../../state/ducks/onion'; import { initialSearchState } from '../../state/ducks/search'; import { initialSectionState } from '../../state/ducks/section'; import { initialThemeState } from '../../state/ducks/theme'; +import { initialUserConfigState } from '../../state/ducks/userConfig'; import { StateType } from '../../state/reducer'; import { makeLookup } from '../../util'; import { LeftPane } from '../LeftPane'; import { SessionMainPanel } from '../SessionMainPanel'; +// tslint:disable-next-line: no-submodule-imports +import { PersistGate } from 'redux-persist/integration/react'; +import { persistStore } from 'redux-persist'; + // Workaround: A react component's required properties are filtering up through connect() // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 @@ -53,13 +58,17 @@ export class SessionInboxView extends React.Component { return <>; } + const persistor = persistStore(this.store); + return ( -
-
- {this.renderLeftPane()} -
- + +
+
+ {this.renderLeftPane()} +
+ + ); } @@ -96,6 +105,7 @@ export class SessionInboxView extends React.Component { mentionsInput: initialMentionsState, onionPaths: initialOnionPathState, modals: initialModalState, + userConfig: initialUserConfigState, }; this.store = createStore(initialState); diff --git a/ts/components/session/conversation/SessionMessagesList.tsx b/ts/components/session/conversation/SessionMessagesList.tsx index ae78e07e9..e3af18792 100644 --- a/ts/components/session/conversation/SessionMessagesList.tsx +++ b/ts/components/session/conversation/SessionMessagesList.tsx @@ -25,6 +25,7 @@ import { DataExtractionNotification } from '../../conversation/DataExtractionNot interface State { showScrollButton: boolean; animateQuotedMessageId?: string; + nextMessageToPlay: number | null; } interface Props { @@ -68,6 +69,7 @@ export class SessionMessagesList extends React.Component { this.state = { showScrollButton: false, + nextMessageToPlay: null, }; autoBind(this); @@ -196,6 +198,7 @@ export class SessionMessagesList extends React.Component { const { conversation, ourPrimary, selectedMessages } = this.props; const multiSelectMode = Boolean(selectedMessages.length); let currentMessageIndex = 0; + let playableMessageIndex = 0; const displayUnreadBannerIndex = this.displayUnreadBannerIndex(messages); return ( @@ -269,6 +272,13 @@ export class SessionMessagesList extends React.Component { return; } + if (messageProps) { + messageProps.nextMessageToPlay = this.state.nextMessageToPlay; + messageProps.playableMessageIndex = playableMessageIndex; + messageProps.playNextMessage = this.playNextMessage; + } + playableMessageIndex++; + if (messageProps.conversationType === ConversationTypeEnum.GROUP) { messageProps.weAreAdmin = conversation.groupAdmins?.includes(ourPrimary); } @@ -373,6 +383,37 @@ export class SessionMessagesList extends React.Component { } } + /** + * Sets the targeted index for the next + * @param index index of message that just completed + */ + private readonly playNextMessage = (index: any) => { + const { messages } = this.props; + let nextIndex: number | null = index - 1; + + // to prevent autoplaying as soon as a message is received. + const latestMessagePlayed = index <= 0 || messages.length < index - 1; + if (latestMessagePlayed) { + nextIndex = null; + this.setState({ + nextMessageToPlay: nextIndex, + }); + return; + } + + // stop auto-playing when the audio messages change author. + const prevAuthorNumber = messages[index].propsForMessage.authorPhoneNumber; + const nextAuthorNumber = messages[index - 1].propsForMessage.authorPhoneNumber; + const differentAuthor = prevAuthorNumber !== nextAuthorNumber; + if (differentAuthor) { + nextIndex = null; + } + + this.setState({ + nextMessageToPlay: nextIndex, + }); + }; + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/ts/components/session/settings/SessionSettings.tsx b/ts/components/session/settings/SessionSettings.tsx index 749898134..d03453754 100644 --- a/ts/components/session/settings/SessionSettings.tsx +++ b/ts/components/session/settings/SessionSettings.tsx @@ -4,18 +4,18 @@ import { SettingsHeader } from './SessionSettingsHeader'; import { SessionSettingListItem } from './SessionSettingListItem'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../SessionButton'; import { BlockedNumberController, PasswordUtil } from '../../../util'; -import { ToastUtils } from '../../../session/utils'; import { ConversationLookupType } from '../../../state/ducks/conversations'; import { StateType } from '../../../state/reducer'; import { getConversationController } from '../../../session/conversations'; import { getConversationLookup } from '../../../state/selectors/conversations'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { getPasswordHash } from '../../../../ts/data/data'; import { SpacerLG, SpacerXS } from '../../basic/Text'; import { shell } from 'electron'; import { SessionConfirmDialogProps } from '../SessionConfirm'; import { mapDispatchToProps } from '../../../state/actions'; import { unblockConvoById } from '../../../interactions/conversationInteractions'; +import { toggleAudioAutoplay } from '../../../state/ducks/userConfig'; import { sessionPassword } from '../../../state/ducks/modalDialog'; import { PasswordAction } from '../SessionPasswordModal'; @@ -264,10 +264,12 @@ class SettingsViewInner extends React.Component { }); } + /** + * If there's a custom afterClick function, execute it instead of automatically updating settings + * @param item setting item + * @param value new value to set + */ public updateSetting(item: any, value?: string) { - // If there's a custom afterClick function, - // execute it instead of automatically updating settings - if (item.setFn) { if (value) { item.setFn(value); @@ -350,6 +352,23 @@ class SettingsViewInner extends React.Component { okTheme: SessionButtonColor.Danger, }, }, + { + id: 'audio-message-autoplay-setting', + title: window.i18n('audioMessageAutoplayTitle'), + description: window.i18n('audioMessageAutoplayDescription'), + hidden: false, + type: SessionSettingType.Toggle, + category: SessionSettingCategory.Appearance, + setFn: () => { + window.inboxStore?.dispatch(toggleAudioAutoplay()); + }, + content: { + defaultValue: window.inboxStore?.getState().userConfig.audioAutoplay, + }, + comparisonValue: undefined, + onClick: undefined, + confirmationDialogParams: undefined, + }, { id: 'notification-setting', title: window.i18n('notificationSettingsDialog'), diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 657815251..cf945bfaa 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -253,4 +253,8 @@ export interface MessageRegularProps { onShowDetail: () => void; markRead: (readAt: number) => Promise; theme: DefaultTheme; + + playableMessageIndex?: number; + nextMessageToPlay?: number; + playNextMessage?: (value: number) => any; } diff --git a/ts/state/createStore.ts b/ts/state/createStore.ts index 8f936c93a..949fb1de2 100644 --- a/ts/state/createStore.ts +++ b/ts/state/createStore.ts @@ -2,6 +2,10 @@ import promise from 'redux-promise-middleware'; import { createLogger } from 'redux-logger'; import { configureStore } from '@reduxjs/toolkit'; import { reducer as allReducers } from './reducer'; +import { persistReducer } from 'redux-persist'; + +// tslint:disable-next-line: no-submodule-imports match-default-export-name +import storage from 'redux-persist/lib/storage'; // @ts-ignore const env = window.getEnvironment(); @@ -22,13 +26,22 @@ const logger = createLogger({ logger: directConsole, }); +const persistConfig = { + key: 'root', + storage, + whitelist: ['userConfig'], +}; + +const persistedReducer = persistReducer(persistConfig, allReducers); + // Exclude logger if we're in production mode const disableLogging = env === 'production' || true; // ALWAYS TURNED OFF const middlewareList = disableLogging ? [promise] : [promise, logger]; export const createStore = (initialState: any) => configureStore({ - reducer: allReducers, + // reducer: allReducers, + reducer: persistedReducer, preloadedState: initialState, middleware: (getDefaultMiddleware: any) => getDefaultMiddleware().concat(middlewareList), }); diff --git a/ts/state/ducks/userConfig.tsx b/ts/state/ducks/userConfig.tsx new file mode 100644 index 000000000..c4f7d4568 --- /dev/null +++ b/ts/state/ducks/userConfig.tsx @@ -0,0 +1,27 @@ +/** + * This slice is intended for the user configurable settings for the client such as appearance, autoplaying of links etc. + * Anything setting under the cog wheel tab. + */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface UserConfigState { + audioAutoplay: boolean; +} + +export const initialUserConfigState = { + audioAutoplay: false, +}; + +const userConfigSlice = createSlice({ + name: 'userConfig', + initialState: initialUserConfigState, + reducers: { + toggleAudioAutoplay: state => { + state.audioAutoplay = !state.audioAutoplay; + }, + }, +}); + +const { actions, reducer } = userConfigSlice; +export const { toggleAudioAutoplay } = actions; +export const userConfigReducer = reducer; diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index ef0947b09..e757789d8 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -12,6 +12,7 @@ import { } from './ducks/mentionsInput'; import { defaultOnionReducer as onionPaths, OnionState } from './ducks/onion'; import { modalReducer as modals, ModalState } from './ducks/modalDialog'; +import { userConfigReducer as userConfig, UserConfigState } from './ducks/userConfig'; export type StateType = { search: SearchStateType; @@ -23,6 +24,7 @@ export type StateType = { mentionsInput: MentionsInputState; onionPaths: OnionState; modals: ModalState; + userConfig: UserConfigState; }; export const reducers = { @@ -35,6 +37,7 @@ export const reducers = { mentionsInput, onionPaths, modals, + userConfig, }; // Making this work would require that our reducer signature supported AnyAction, not diff --git a/ts/state/selectors/userConfig.ts b/ts/state/selectors/userConfig.ts new file mode 100644 index 000000000..084f8d9ac --- /dev/null +++ b/ts/state/selectors/userConfig.ts @@ -0,0 +1,10 @@ +import { StateType } from '../reducer'; +import { UserConfigState } from '../ducks/userConfig'; +import { createSelector } from 'reselect'; + +export const getUserConfig = (state: StateType): UserConfigState => state.userConfig; + +export const getAudioAutoplay = createSelector( + getUserConfig, + (state: UserConfigState): boolean => state.audioAutoplay +); diff --git a/yarn.lock b/yarn.lock index c7405d35e..e566548e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9660,6 +9660,11 @@ redux-logger@3.0.6: dependencies: deep-diff "^0.3.5" +redux-persist@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" + integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== + redux-promise-middleware@6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-6.1.0.tgz#ecdb22488cdd673c1a3f0d278d82b48d92ca5d06"