diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0c4b9f3feae6..bb2f47ee57ec 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5623,6 +5623,14 @@ "message": "Story settings", "description": "Button label to get to story settings" }, + "StoriesSettings__view-receipts--label": { + "message": "View Receipts", + "description": "Label of view receipts checkbox in story settings" + }, + "StoriesSettings__view-receipts--description": { + "message": "To change this setting, open the Signal app on your mobile device and navigate to Settings -> Stories", + "description": "Description of how view receipts can be changed in story settings" + }, "SendStoryModal__choose-who-can-view": { "message": "Choose who can view your story", "description": "Shown during the first time posting a story" @@ -5732,7 +5740,7 @@ "description": "Context menu item to help debugging" }, "StoryViewsNRepliesModal__read-receipts-off": { - "message": "Enable read receipts to see who's viewed your stories. Open the Signal app on your mobile device and navigate to Settings > Privacy.", + "message": "Enable view receipts to see who’s viewed your stories. Open the Signal app on your mobile device and navigate to Settings > Stories.", "description": "Instructions on how to enable read receipts" }, "StoryViewsNRepliesModal__no-replies": { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 3dbe6b4c6a87..10a6dc0ddcf0 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -6,6 +6,12 @@ package signalservice; option java_package = "org.whispersystems.signalservice.internal.storage"; option java_outer_classname = "SignalStorageProtos"; +enum OptionalBool { + UNSET = 0; + ENABLED = 1; + DISABLED = 2; +} + message StorageManifest { optional uint64 version = 1; optional bytes value = 2; @@ -166,6 +172,7 @@ message AccountRecord { reserved /* hasViewedOnboardingStory */ 27; reserved 28; // deprecatedStoriesDisabled optional bool storiesDisabled = 29; + optional OptionalBool storyViewReceiptsEnabled = 30; } message StoryDistributionListRecord { diff --git a/ts/background.ts b/ts/background.ts index b8e37785a4f7..fada558e6fe9 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -3698,7 +3698,7 @@ export async function startApp(): Promise { event.confirm(); - if (!window.storage.get('read-receipt-setting') || !sourceConversation) { + if (!sourceConversation) { return; } diff --git a/ts/components/MyStories.stories.tsx b/ts/components/MyStories.stories.tsx index 5b5db1ac2235..73d0ce74950a 100644 --- a/ts/components/MyStories.stories.tsx +++ b/ts/components/MyStories.stories.tsx @@ -41,6 +41,10 @@ export default { ourConversationId: { defaultValue: getDefaultConversation().id, }, + hasViewReceiptSetting: { + control: 'boolean', + defaultValue: false, + }, queueStoryDownload: { action: true, }, diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index b02b0fefaa8b..afa10a3f0cb2 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -23,7 +23,7 @@ export type PropsType = { onSave: (story: StoryViewType) => unknown; queueStoryDownload: (storyId: string) => unknown; viewStory: ViewStoryActionCreatorType; - hasReadReceiptSetting: boolean; + hasViewReceiptSetting: boolean; }; export const MyStories = ({ @@ -35,7 +35,7 @@ export const MyStories = ({ onSave, queueStoryDownload, viewStory, - hasReadReceiptSetting, + hasViewReceiptSetting, }: PropsType): JSX.Element => { const [confirmDeleteStory, setConfirmDeleteStory] = useState< StoryViewType | undefined @@ -107,7 +107,7 @@ export const MyStories = ({ />
- {hasReadReceiptSetting + {hasViewReceiptSetting ? i18n('icu:MyStories__views', { views: story.views ?? 0, }) diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index 3d7764a01f34..a6214fa6daca 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -48,7 +48,7 @@ export type PropsType = { viewStory: ViewStoryActionCreatorType; isViewingStory: boolean; isStoriesSettingsVisible: boolean; - hasReadReceiptSetting: boolean; + hasViewReceiptSetting: boolean; }; type AddStoryType = @@ -81,7 +81,7 @@ export const Stories = ({ viewStory, isViewingStory, isStoriesSettingsVisible, - hasReadReceiptSetting, + hasViewReceiptSetting, }: PropsType): JSX.Element => { const width = getWidthFromPreferredWidth(preferredWidthFromStorage, { requiresFullWidth: true, @@ -118,7 +118,7 @@ export const Stories = ({ onSave={onSaveStory} queueStoryDownload={queueStoryDownload} viewStory={viewStory} - hasReadReceiptSetting={hasReadReceiptSetting} + hasViewReceiptSetting={hasViewReceiptSetting} /> ) : ( ) => unknown; setMyStoriesToAllSignalConnections: () => unknown; + storyViewReceiptsEnabled: boolean; toggleSignalConnectionsModal: () => unknown; }; @@ -102,6 +103,7 @@ export const StoriesSettingsModal = ({ onRepliesNReactionsChanged, onViewersUpdated, setMyStoriesToAllSignalConnections, + storyViewReceiptsEnabled, toggleSignalConnectionsModal, }: PropsType): JSX.Element => { const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n); @@ -239,8 +241,6 @@ export const StoriesSettingsModal = ({ -
- ))} + +
+ + ); } diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index 483808c4f19d..758e29a54b47 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -29,9 +29,11 @@ export default { defaultValue: undefined, }, hasAllStoriesMuted: { + control: 'boolean', defaultValue: false, }, - hasReadReceiptSetting: { + hasViewReceiptSetting: { + control: 'boolean', defaultValue: true, }, i18n: { @@ -194,7 +196,7 @@ export const ReadReceiptsOff = Template.bind({}); '/fixtures/nathan-anderson-316188-unsplash.jpg' ); ReadReceiptsOff.args = { - hasReadReceiptSetting: false, + hasViewReceiptSetting: false, story: { ...storyView, sender: { diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index bda65923314b..e1ce065adee2 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -67,7 +67,7 @@ export type PropsType = { >; hasActiveCall?: boolean; hasAllStoriesMuted: boolean; - hasReadReceiptSetting: boolean; + hasViewReceiptSetting: boolean; i18n: LocalizerType; loadStoryReplies: (conversationId: string, messageId: string) => unknown; markStoryRead: (mId: string) => unknown; @@ -117,7 +117,7 @@ export const StoryViewer = ({ group, hasActiveCall, hasAllStoriesMuted, - hasReadReceiptSetting, + hasViewReceiptSetting, i18n, loadStoryReplies, markStoryRead, @@ -722,11 +722,11 @@ export const StoryViewer = ({ <> {isSent || replyCount > 0 ? ( - {isSent && !hasReadReceiptSetting && !replyCount && ( + {isSent && !hasViewReceiptSetting && !replyCount && ( <>{i18n('StoryViewer__views-off')} )} {isSent && - hasReadReceiptSetting && + hasViewReceiptSetting && (viewCount === 1 ? ( | undefined; @@ -124,7 +124,7 @@ export const StoryViewsNRepliesModal = ({ authorTitle, canReply, getPreferredBadge, - hasReadReceiptSetting, + hasViewReceiptSetting, hasViewsCapability, i18n, group, @@ -396,7 +396,7 @@ export const StoryViewsNRepliesModal = ({ } let viewsElement: JSX.Element | undefined; - if (hasViewsCapability && !hasReadReceiptSetting) { + if (hasViewsCapability && !hasViewReceiptSetting) { viewsElement = (
{i18n('StoryViewsNRepliesModal__read-receipts-off')} diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index 9030d084b7b4..0f36f640586e 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -118,6 +118,26 @@ const wasDeliveredWithSealedSender = ( conversationId ); +const shouldDropReceipt = ( + receipt: MessageReceiptModel, + message: MessageModel +): boolean => { + const type = receipt.get('type'); + switch (type) { + case MessageReceiptType.Delivery: + return false; + case MessageReceiptType.Read: + return !window.storage.get('read-receipt-setting'); + case MessageReceiptType.View: + if (isStory(message.attributes)) { + return !window.Events.getStoryViewReceiptsEnabled(); + } + return !window.storage.get('read-receipt-setting'); + default: + throw missingCaseError(type); + } +}; + export class MessageReceipts extends Collection { static getSingleton(): MessageReceipts { if (!singleton) { @@ -140,16 +160,27 @@ export class MessageReceipts extends Collection { } else { ids = conversation.getMemberIds(); } + const sentAt = message.get('sent_at'); const receipts = this.filter( receipt => - receipt.get('messageSentAt') === message.get('sent_at') && + receipt.get('messageSentAt') === sentAt && ids.includes(receipt.get('sourceConversationId')) ); if (receipts.length) { - log.info('Found early receipts for message'); + log.info(`MessageReceipts: found early receipts for message ${sentAt}`); this.remove(receipts); } - return receipts; + return receipts.filter(receipt => { + if (shouldDropReceipt(receipt, message)) { + log.info( + `MessageReceipts: Dropping an early receipt ${receipt.get('type')} ` + + `for message ${sentAt}` + ); + return false; + } + + return true; + }); } private async updateMessageSendState( @@ -157,6 +188,15 @@ export class MessageReceipts extends Collection { message: MessageModel ): Promise { const messageSentAt = receipt.get('messageSentAt'); + + if (shouldDropReceipt(receipt, message)) { + log.info( + `MessageReceipts: Dropping a receipt ${receipt.get('type')} ` + + `for message ${messageSentAt}` + ); + return; + } + const receiptTimestamp = receipt.get('receiptTimestamp'); const sourceConversationId = receipt.get('sourceConversationId'); const type = receipt.get('type'); @@ -272,7 +312,7 @@ export class MessageReceipts extends Collection { // Nope, no target message was found if (!targetMessages.length) { log.info( - 'No message for receipt', + 'MessageReceipts: No message for receipt', type, sourceConversationId, sourceUuid, diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index c90a0835ca2a..3257be7b8d22 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -362,6 +362,17 @@ export function toAccountRecord( const hasStoriesDisabled = window.storage.get('hasStoriesDisabled'); accountRecord.storiesDisabled = hasStoriesDisabled === true; + const storyViewReceiptsEnabled = window.storage.get( + 'storyViewReceiptsEnabled' + ); + if (storyViewReceiptsEnabled !== undefined) { + accountRecord.storyViewReceiptsEnabled = storyViewReceiptsEnabled + ? Proto.OptionalBool.ENABLED + : Proto.OptionalBool.DISABLED; + } else { + accountRecord.storyViewReceiptsEnabled = Proto.OptionalBool.UNSET; + } + applyUnknownFields(accountRecord, conversation); return accountRecord; @@ -1093,6 +1104,7 @@ export async function mergeAccountRecord( keepMutedChatsArchived, hasSetMyStoriesPrivacy, storiesDisabled, + storyViewReceiptsEnabled, } = accountRecord; const updatedConversations = new Array(); @@ -1292,6 +1304,19 @@ export async function mergeAccountRecord( window.textsecure.server?.onHasStoriesDisabledChange(hasStoriesDisabled); } + switch (storyViewReceiptsEnabled) { + case Proto.OptionalBool.ENABLED: + window.storage.put('storyViewReceiptsEnabled', true); + break; + case Proto.OptionalBool.DISABLED: + window.storage.put('storyViewReceiptsEnabled', false); + break; + case Proto.OptionalBool.UNSET: + default: + // Do nothing + break; + } + const ourID = window.ConversationController.getOurConversationId(); if (!ourID) { diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 50511b8a799e..d1bfb0ee856c 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -320,7 +320,9 @@ function markStoryRead( viewSyncJobQueue.add({ viewSyncs }); } - viewedReceiptsJobQueue.add({ viewedReceipt }); + if (window.Events.getStoryViewReceiptsEnabled()) { + viewedReceiptsJobQueue.add({ viewedReceipt }); + } await dataInterface.addNewStoryRead({ authorId: message.attributes.sourceUuid, diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index e6e36f4405e6..c4d11a271917 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -177,3 +177,11 @@ export const getHasReadReceiptSetting = createSelector( getItems, (state: ItemsStateType): boolean => Boolean(state['read-receipt-setting']) ); + +export const getHasStoryViewReceiptSetting = createSelector( + getItems, + (state: ItemsStateType): boolean => + Boolean( + state.storyViewReceiptsEnabled ?? state['read-receipt-setting'] ?? false + ) +); diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index 004adc5f24ee..070cec5dccb8 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -13,7 +13,7 @@ import { getMe } from '../selectors/conversations'; import { getIntl } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { - getHasReadReceiptSetting, + getHasStoryViewReceiptSetting, getPreferredLeftPaneWidth, } from '../selectors/items'; import { @@ -62,7 +62,7 @@ export function SmartStories(): JSX.Element | null { (state: StateType) => state.globalModals.isStoriesSettingsVisible ); - const hasReadReceiptSetting = useSelector(getHasReadReceiptSetting); + const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting); if (!isShowingStoriesView) { return null; @@ -92,7 +92,7 @@ export function SmartStories(): JSX.Element | null { toggleHideStories={toggleHideStories} isViewingStory={selectedStoryData !== undefined} isStoriesSettingsVisible={isStoriesSettingsVisible} - hasReadReceiptSetting={hasReadReceiptSetting} + hasViewReceiptSetting={hasViewReceiptSetting} {...storiesActions} /> ); diff --git a/ts/state/smart/StoriesSettingsModal.tsx b/ts/state/smart/StoriesSettingsModal.tsx index e759d79b6390..8a34a6678ba9 100644 --- a/ts/state/smart/StoriesSettingsModal.tsx +++ b/ts/state/smart/StoriesSettingsModal.tsx @@ -14,6 +14,7 @@ import { import { getDistributionListsWithMembers } from '../selectors/storyDistributionLists'; import { getIntl } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; +import { getHasStoryViewReceiptSetting } from '../selectors/items'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists'; @@ -31,6 +32,7 @@ export function SmartStoriesSettingsModal(): JSX.Element | null { } = useStoryDistributionListsActions(); const getPreferredBadge = useSelector(getPreferredBadgeSelector); + const storyViewReceiptsEnabled = useSelector(getHasStoryViewReceiptSetting); const i18n = useSelector(getIntl); const me = useSelector(getMe); @@ -52,6 +54,7 @@ export function SmartStoriesSettingsModal(): JSX.Element | null { onRepliesNReactionsChanged={allowsRepliesChanged} onViewersUpdated={updateStoryViewers} setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} + storyViewReceiptsEnabled={storyViewReceiptsEnabled} toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> ); diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index eed62664e3da..52537d726217 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -14,7 +14,7 @@ import { getConversationSelector } from '../selectors/conversations'; import { getEmojiSkinTone, getHasAllStoriesMuted, - getHasReadReceiptSetting, + getHasStoryViewReceiptSetting, getPreferredReactionEmoji, } from '../selectors/items'; import { getIntl } from '../selectors/user'; @@ -67,8 +67,8 @@ export function SmartStoryViewer(): JSX.Element | null { ); const hasActiveCall = useSelector(isInFullScreenCall); - const hasReadReceiptSetting = useSelector( - getHasReadReceiptSetting + const hasViewReceiptSetting = useSelector( + getHasStoryViewReceiptSetting ); const storyInfo = getStoryById( @@ -90,7 +90,7 @@ export function SmartStoryViewer(): JSX.Element | null { group={conversationStory.group} hasActiveCall={hasActiveCall} hasAllStoriesMuted={hasAllStoriesMuted} - hasReadReceiptSetting={hasReadReceiptSetting} + hasViewReceiptSetting={hasViewReceiptSetting} i18n={i18n} numStories={selectedStoryData.numStories} onHideStory={toggleHideStories} diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index f6edbb435a4f..0edccba534f3 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -68,6 +68,7 @@ export type StorageAccessType = { hasRegisterSupportForUnauthenticatedDelivery: boolean; hasSetMyStoriesPrivacy: boolean; hasStoriesDisabled: boolean; + storyViewReceiptsEnabled: boolean; identityKeyMap: IdentityKeyMap; lastHeartbeat: number; lastStartup: number; diff --git a/ts/util/createIPCEvents.tsx b/ts/util/createIPCEvents.tsx index 511c0ec61680..1d48772f3cde 100644 --- a/ts/util/createIPCEvents.tsx +++ b/ts/util/createIPCEvents.tsx @@ -68,6 +68,7 @@ export type IPCEventsValuesType = { themeSetting: ThemeType; universalExpireTimer: number; zoomFactor: ZoomFactorType; + storyViewReceiptsEnabled: boolean; // Optional mediaPermissions: boolean; @@ -193,6 +194,16 @@ export function createIPCEvents( account.captureChange('hasStoriesDisabled'); window.textsecure.server?.onHasStoriesDisabledChange(value); }, + getStoryViewReceiptsEnabled: () => { + return ( + window.storage.get('storyViewReceiptsEnabled') ?? + window.storage.get('read-receipt-setting') ?? + false + ); + }, + setStoryViewReceiptsEnabled: async (value: boolean) => { + await window.storage.put('storyViewReceiptsEnabled', value); + }, getPreferredAudioInputDevice: () => window.storage.get('preferred-audio-input-device'),