From 67b17ec317e1aad7afa15e34c2e18b479f1c658d Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 30 Nov 2021 10:29:57 -0600 Subject: [PATCH] Hide "become a sustainer" button if you're already a sustainer --- protos/SignalService.proto | 7 +- protos/SignalStorage.proto | 3 + ts/background.ts | 8 ++ ts/components/BadgeDialog.stories.tsx | 5 + ts/components/BadgeDialog.tsx | 26 ++-- .../conversation/ContactModal.stories.tsx | 1 + ts/components/conversation/ContactModal.tsx | 3 + .../ConversationDetails.stories.tsx | 1 + .../ConversationDetails.tsx | 3 + .../ConversationDetailsHeader.stories.tsx | 1 + .../ConversationDetailsHeader.tsx | 3 + ts/services/areWeASubscriber.ts | 36 +++++ ts/services/storageRecordOps.ts | 24 ++++ ts/sql/Client.ts | 1 + ts/state/ducks/items.ts | 2 + ts/state/selectors/items.ts | 6 + ts/state/smart/ContactModal.tsx | 2 + ts/state/smart/ConversationDetails.tsx | 2 + ts/test-both/state/selectors/items_test.ts | 16 +++ .../services/areWeASubscriber_test.ts | 130 ++++++++++++++++++ ts/textsecure/WebAPI.ts | 60 ++++++-- ts/types/Storage.d.ts | 4 + 22 files changed, 318 insertions(+), 26 deletions(-) create mode 100644 ts/services/areWeASubscriber.ts create mode 100644 ts/test-electron/services/areWeASubscriber_test.ts diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 4e9675b9e..811a2b42e 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -441,9 +441,10 @@ message SyncMessage { message FetchLatest { enum Type { - UNKNOWN = 0; - LOCAL_PROFILE = 1; - STORAGE_MANIFEST = 2; + UNKNOWN = 0; + LOCAL_PROFILE = 1; + STORAGE_MANIFEST = 2; + SUBSCRIPTION_STATUS = 3; } optional Type type = 1; diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index ba0929426..a43162c49 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -137,4 +137,7 @@ message AccountRecord { optional bool primarySendsSms = 18; optional string e164 = 19; repeated string preferredReactionEmoji = 20; + optional bytes subscriberId = 21; + optional string subscriberCurrencyCode = 22; + optional bool displayBadgesOnProfile = 23; } diff --git a/ts/background.ts b/ts/background.ts index 85d539928..d75542712 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -50,6 +50,7 @@ import { initializeAllJobQueues } from './jobs/initializeAllJobQueues'; import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue'; import { ourProfileKeyService } from './services/ourProfileKey'; import { notificationService } from './services/notifications'; +import { areWeASubscriberService } from './services/areWeASubscriber'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; import { LatestQueue } from './util/LatestQueue'; import { parseIntOrThrow } from './util/parseIntOrThrow'; @@ -346,6 +347,8 @@ export async function startApp(): Promise { onlineEventTarget: window, storage: window.storage, }); + + areWeASubscriberService.update(window.storage, server); }); const eventHandlerQueue = new window.PQueue({ @@ -3477,6 +3480,11 @@ export async function startApp(): Promise { log.info('onFetchLatestSync: fetching latest manifest'); await window.Signal.Services.runStorageServiceSyncJob(); break; + case FETCH_LATEST_ENUM.SUBSCRIPTION_STATUS: + log.info('onFetchLatestSync: fetching latest subscription status'); + strictAssert(server, 'WebAPI not ready'); + areWeASubscriberService.update(window.storage, server); + break; default: log.info(`onFetchLatestSync: Unknown type encountered ${eventType}`); } diff --git a/ts/components/BadgeDialog.stories.tsx b/ts/components/BadgeDialog.stories.tsx index 8b9b034a3..3a6abf5cc 100644 --- a/ts/components/BadgeDialog.stories.tsx +++ b/ts/components/BadgeDialog.stories.tsx @@ -18,6 +18,7 @@ const i18n = setupI18n('en', enMessages); const story = storiesOf('Components/BadgeDialog', module); const defaultProps: ComponentProps = { + areWeASubscriber: false, badges: getFakeBadges(3), firstName: 'Alice', i18n, @@ -95,3 +96,7 @@ story.add('Five badges', () => ( story.add('Many badges', () => ( )); + +story.add('Many badges, user is a subscriber', () => ( + +)); diff --git a/ts/components/BadgeDialog.tsx b/ts/components/BadgeDialog.tsx index 3ead43b00..3295e33b7 100644 --- a/ts/components/BadgeDialog.tsx +++ b/ts/components/BadgeDialog.tsx @@ -16,6 +16,7 @@ import { BadgeCarouselIndex } from './BadgeCarouselIndex'; import { BadgeSustainerInstructionsDialog } from './BadgeSustainerInstructionsDialog'; type PropsType = Readonly<{ + areWeASubscriber: boolean; badges: ReadonlyArray; firstName?: string; i18n: LocalizerType; @@ -53,6 +54,7 @@ export function BadgeDialog(props: PropsType): null | JSX.Element { } function BadgeDialogWithBadges({ + areWeASubscriber, badges, firstName, i18n, @@ -114,17 +116,19 @@ function BadgeDialogWithBadges({ title={title} /> - + {!areWeASubscriber && ( + + )} = {}): PropsType => ({ + areWeASubscriber: false, areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false), badges: overrideProps.badges || [], contact: overrideProps.contact || defaultContact, diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 9547e1a58..d462c390b 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -16,6 +16,7 @@ import { SharedGroupNames } from '../SharedGroupNames'; import { ConfirmationDialog } from '../ConfirmationDialog'; export type PropsDataType = { + areWeASubscriber: boolean; areWeAdmin: boolean; badges: ReadonlyArray; contact?: ConversationType; @@ -50,6 +51,7 @@ enum ContactModalView { } export const ContactModal = ({ + areWeASubscriber, areWeAdmin, badges, contact, @@ -219,6 +221,7 @@ export const ContactModal = ({ case ContactModalView.ShowingBadges: return ( ({ addMembers: async () => { action('addMembers'); }, + areWeASubscriber: false, canEditGroupInfo: false, candidateContactsToAdd: times(10, () => getDefaultConversation()), conversation: expireTimer diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 71a8e6376..dc551e4ef 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -55,6 +55,7 @@ enum ModalState { export type StateProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; + areWeASubscriber: boolean; badges?: ReadonlyArray; canEditGroupInfo: boolean; candidateContactsToAdd: Array; @@ -109,6 +110,7 @@ export type Props = StateProps & ActionProps; export const ConversationDetails: React.ComponentType = ({ addMembers, + areWeASubscriber, badges, canEditGroupInfo, candidateContactsToAdd, @@ -316,6 +318,7 @@ export const ConversationDetails: React.ComponentType = ({ )} ) => { return ( ; canEdit: boolean; conversation: ConversationType; @@ -36,6 +37,7 @@ enum ConversationDetailsHeaderActiveModal { const bem = bemGenerator('ConversationDetails-header'); export const ConversationDetailsHeader: React.ComponentType = ({ + areWeASubscriber, badges, canEdit, conversation, @@ -128,6 +130,7 @@ export const ConversationDetailsHeader: React.ComponentType = ({ case ConversationDetailsHeaderActiveModal.ShowingBadges: modal = ( , + server: Pick + ): void { + this.queue.add(async () => { + await new Promise(resolve => storage.onready(resolve)); + + const subscriberId = storage.get('subscriberId'); + if (!subscriberId || !subscriberId.byteLength) { + storage.put('areWeASubscriber', false); + return; + } + + await waitForOnline(navigator, window); + + storage.put( + 'areWeASubscriber', + await server.getHasSubscription(subscriberId) + ); + }); + } +} + +export const areWeASubscriberService = new AreWeASubscriberService(); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 2aaa78428..c8c91fd7f 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -293,6 +293,19 @@ export async function toAccountRecord( ); accountRecord.pinnedConversations = pinnedConversations; + + const subscriberId = window.storage.get('subscriberId'); + if (subscriberId instanceof Uint8Array) { + accountRecord.subscriberId = subscriberId; + } + const subscriberCurrencyCode = window.storage.get('subscriberCurrencyCode'); + if (typeof subscriberCurrencyCode === 'string') { + accountRecord.subscriberCurrencyCode = subscriberCurrencyCode; + } + accountRecord.displayBadgesOnProfile = Boolean( + window.storage.get('displayBadgesOnProfile') + ); + applyUnknownFields(accountRecord, conversation); return accountRecord; @@ -845,6 +858,9 @@ export async function mergeAccountRecord( universalExpireTimer, e164: accountE164, preferredReactionEmoji: rawPreferredReactionEmoji, + subscriberId, + subscriberCurrencyCode, + displayBadgesOnProfile, } = accountRecord; window.storage.put('read-receipt-setting', Boolean(readReceipts)); @@ -1018,6 +1034,14 @@ export async function mergeAccountRecord( window.storage.put('pinnedConversationIds', remotelyPinnedConversationIds); } + if (subscriberId instanceof Uint8Array) { + window.storage.put('subscriberId', subscriberId); + } + if (typeof subscriberCurrencyCode === 'string') { + window.storage.put('subscriberCurrencyCode', subscriberCurrencyCode); + } + window.storage.put('displayBadgesOnProfile', Boolean(displayBadgesOnProfile)); + const ourID = window.ConversationController.getOurConversationId(); if (!ourID) { diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 4da443076..930c4125c 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -772,6 +772,7 @@ async function removeAllSignedPreKeys() { const ITEM_KEYS: Partial>> = { senderCertificate: ['value.serialized'], senderCertificateNoE164: ['value.serialized'], + subscriberId: ['value'], profileKey: ['value'], }; async function createOrUpdateItem(data: ItemType) { diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index 9f5bfc936..b604ad15a 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -36,6 +36,8 @@ export type ItemsStateType = { readonly preferredLeftPaneWidth?: number; readonly preferredReactionEmoji?: Array; + + readonly areWeASubscriber?: boolean; }; // Actions diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 33bcaa35d..641f6d201 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -20,6 +20,12 @@ const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; export const getItems = (state: StateType): ItemsStateType => state.items; +export const getAreWeASubscriber = createSelector( + getItems, + ({ areWeASubscriber }: Readonly): boolean => + Boolean(areWeASubscriber) +); + export const getUserAgent = createSelector( getItems, (state: ItemsStateType): string => state.userAgent as string diff --git a/ts/state/smart/ContactModal.tsx b/ts/state/smart/ContactModal.tsx index 4f76f31bc..247917be1 100644 --- a/ts/state/smart/ContactModal.tsx +++ b/ts/state/smart/ContactModal.tsx @@ -7,6 +7,7 @@ import type { PropsDataType } from '../../components/conversation/ContactModal'; import { ContactModal } from '../../components/conversation/ContactModal'; import type { StateType } from '../reducer'; +import { getAreWeASubscriber } from '../selectors/items'; import { getIntl, getTheme } from '../selectors/user'; import { getBadgesSelector } from '../selectors/badges'; import { getConversationSelector } from '../selectors/conversations'; @@ -35,6 +36,7 @@ const mapStateToProps = (state: StateType): PropsDataType => { } return { + areWeASubscriber: getAreWeASubscriber(state), areWeAdmin, badges: getBadgesSelector(state)(contact.badges), contact, diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index c4badd23f..0fd5c3407 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -13,6 +13,7 @@ import { getConversationByUuidSelector, } from '../selectors/conversations'; import { getGroupMemberships } from '../../util/getGroupMemberships'; +import { getAreWeASubscriber } from '../selectors/items'; import { getIntl, getTheme } from '../selectors/user'; import type { MediaItemType } from '../../types/MediaItem'; import { @@ -82,6 +83,7 @@ const mapStateToProps = ( return { ...props, + areWeASubscriber: getAreWeASubscriber(state), badges, canEditGroupInfo, candidateContactsToAdd, diff --git a/ts/test-both/state/selectors/items_test.ts b/ts/test-both/state/selectors/items_test.ts index 522372953..c9e425bab 100644 --- a/ts/test-both/state/selectors/items_test.ts +++ b/ts/test-both/state/selectors/items_test.ts @@ -3,6 +3,7 @@ import { assert } from 'chai'; import { + getAreWeASubscriber, getEmojiSkinTone, getPinnedConversationIds, getPreferredLeftPaneWidth, @@ -21,6 +22,21 @@ describe('both/state/selectors/items', () => { } as any; } + describe('#getAreWeASubscriber', () => { + it('returns false if the value is not in storage', () => { + assert.isFalse(getAreWeASubscriber(getRootState({}))); + }); + + it('returns the value in storage', () => { + assert.isFalse( + getAreWeASubscriber(getRootState({ areWeASubscriber: false })) + ); + assert.isTrue( + getAreWeASubscriber(getRootState({ areWeASubscriber: true })) + ); + }); + }); + describe('#getEmojiSkinTone', () => { it('returns 0 if passed anything invalid', () => { [ diff --git a/ts/test-electron/services/areWeASubscriber_test.ts b/ts/test-electron/services/areWeASubscriber_test.ts new file mode 100644 index 000000000..29b782c12 --- /dev/null +++ b/ts/test-electron/services/areWeASubscriber_test.ts @@ -0,0 +1,130 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { AreWeASubscriberService } from '../../services/areWeASubscriber'; +import { explodePromise } from '../../util/explodePromise'; + +describe('"are we a subscriber?" service', () => { + const subscriberId = new Uint8Array([1, 2, 3]); + const fakeStorageDefaults = { + onready: sinon.stub().callsArg(0), + get: sinon.stub().withArgs('subscriberId').returns(subscriberId), + }; + + let sandbox: sinon.SinonSandbox; + let service: AreWeASubscriberService; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + service = new AreWeASubscriberService(); + sandbox.stub(navigator, 'onLine').get(() => true); + }); + + it("stores false if there's no local subscriber ID", done => { + const fakeServer = { getHasSubscription: sandbox.stub() }; + const fakeStorage = { + ...fakeStorageDefaults, + get: () => undefined, + put: sandbox.stub().callsFake((key, value) => { + assert.strictEqual(key, 'areWeASubscriber'); + assert.isFalse(value); + done(); + }), + }; + + service.update(fakeStorage, fakeServer); + }); + + it("doesn't make a network request if there's no local subscriber ID", done => { + const fakeServer = { getHasSubscription: sandbox.stub() }; + const fakeStorage = { + ...fakeStorageDefaults, + get: () => undefined, + put: sandbox.stub().callsFake(() => { + sinon.assert.notCalled(fakeServer.getHasSubscription); + done(); + }), + }; + + service.update(fakeStorage, fakeServer); + }); + + it('requests the subscriber ID from the server', done => { + const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; + const fakeStorage = { + ...fakeStorageDefaults, + put: sandbox + .stub() + .withArgs('areWeASubscriber') + .callsFake(() => { + sinon.assert.calledWithExactly( + fakeServer.getHasSubscription, + subscriberId + ); + done(); + }), + }; + + service.update(fakeStorage, fakeServer); + }); + + it("stores when we're not a subscriber", done => { + const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; + const fakeStorage = { + ...fakeStorageDefaults, + put: sandbox.stub().callsFake((key, value) => { + assert.strictEqual(key, 'areWeASubscriber'); + assert.isFalse(value); + done(); + }), + }; + + service.update(fakeStorage, fakeServer); + }); + + it("stores when we're a subscriber", done => { + const fakeServer = { getHasSubscription: sandbox.stub().resolves(true) }; + const fakeStorage = { + ...fakeStorageDefaults, + put: sandbox.stub().callsFake((key, value) => { + assert.strictEqual(key, 'areWeASubscriber'); + assert.isTrue(value); + done(); + }), + }; + + service.update(fakeStorage, fakeServer); + }); + + it('only runs one request at a time and enqueues one other', async () => { + const allDone = explodePromise(); + let putCallCount = 0; + + const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; + const fakeStorage = { + ...fakeStorageDefaults, + put: sandbox.stub().callsFake(() => { + putCallCount += 1; + if (putCallCount === 2) { + allDone.resolve(); + } else if (putCallCount > 2) { + throw new Error('Unexpected call to storage put'); + } + }), + }; + + service.update(fakeStorage, fakeServer); + service.update(fakeStorage, fakeServer); + service.update(fakeStorage, fakeServer); + service.update(fakeStorage, fakeServer); + service.update(fakeStorage, fakeServer); + + await allDone.promise; + + sinon.assert.calledTwice(fakeServer.getHasSubscription); + sinon.assert.calledTwice(fakeStorage.put); + }); +}); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 0bf8c80a4..5ee41c891 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -26,6 +26,7 @@ import Long from 'long'; import type { Readable } from 'stream'; import { assert, strictAssert } from '../util/assert'; +import { isRecord } from '../util/isRecord'; import * as durations from '../util/durations'; import { getUserAgent } from '../util/getUserAgent'; import { getStreamWithTimeout } from '../util/getStreamWithTimeout'; @@ -169,7 +170,6 @@ type RedactUrl = (url: string) => string; type PromiseAjaxOptionsType = { socketManager?: SocketManager; - accessKey?: string; basicAuth?: string; certificateAuthority?: string; contentType?: string; @@ -191,12 +191,20 @@ type PromiseAjaxOptionsType = { stack?: string; timeout?: number; type: HTTPCodeType; - unauthenticated?: boolean; user?: string; validateResponse?: any; version: string; abortSignal?: AbortSignal; -}; +} & ( + | { + unauthenticated?: false; + accessKey?: string; + } + | { + unauthenticated: true; + accessKey: undefined | string; + } +); type JSONWithDetailsType = { data: unknown; @@ -321,13 +329,10 @@ async function _promiseAjax( if (basicAuth) { fetchOptions.headers.Authorization = `Basic ${basicAuth}`; } else if (unauthenticated) { - if (!accessKey) { - throw new Error( - '_promiseAjax: mode is unauthenticated, but accessKey was not provided' - ); + if (accessKey) { + // Access key is already a Base64 string + fetchOptions.headers['Unidentified-Access-Key'] = accessKey; } - // Access key is already a Base64 string - fetchOptions.headers['Unidentified-Access-Key'] = accessKey; } else if (options.user && options.password) { const auth = Bytes.toBase64( Bytes.fromString(`${options.user}:${options.password}`) @@ -542,6 +547,7 @@ const URL_CALLS = { storageModify: 'v1/storage/', storageRead: 'v1/storage/read', storageToken: 'v1/storage/auth', + subscriptions: 'v1/subscription', supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', updateDeviceName: 'v1/accounts/name', username: 'v1/accounts/username', @@ -608,7 +614,6 @@ export type MessageType = Readonly<{ }>; type AjaxOptionsType = { - accessKey?: string; basicAuth?: string; call: keyof typeof URL_CALLS; contentType?: string; @@ -622,11 +627,19 @@ type AjaxOptionsType = { responseType?: 'json' | 'bytes' | 'byteswithdetails' | 'stream'; schema?: unknown; timeout?: number; - unauthenticated?: boolean; urlParameters?: string; username?: string; validateResponse?: any; -}; +} & ( + | { + unauthenticated?: false; + accessKey?: string; + } + | { + unauthenticated: true; + accessKey: undefined | string; + } +); export type WebAPIConnectOptionsType = WebAPICredentials & { useWebSocket?: boolean; @@ -753,6 +766,7 @@ export type WebAPIType = { getAttachment: (cdnKey: string, cdnNumber?: number) => Promise; getAvatar: (path: string) => Promise; getDevices: () => Promise; + getHasSubscription: (subscriberId: Uint8Array) => Promise; getGroup: (options: GroupCredentialsType) => Promise; getGroupFromLink: ( inviteLinkPassword: string, @@ -1092,6 +1106,7 @@ export function initialize({ getGroupExternalCredential, getGroupFromLink, getGroupLog, + getHasSubscription, getIceServers, getKeysForIdentifier, getKeysForIdentifierUnauth, @@ -2493,6 +2508,27 @@ export function initialize({ }; } + async function getHasSubscription( + subscriberId: Uint8Array + ): Promise { + const formattedId = toWebSafeBase64(Bytes.toBase64(subscriberId)); + const data = await _ajax({ + call: 'subscriptions', + httpType: 'GET', + urlParameters: `/${formattedId}`, + responseType: 'json', + unauthenticated: true, + accessKey: undefined, + redactUrl: _createRedactor(formattedId), + }); + + return ( + isRecord(data) && + isRecord(data.subscription) && + Boolean(data.subscription.active) + ); + } + function getProvisioningResource( handler: IRequestHandler ): Promise { diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index a3c1a7a1c..fb0e13140 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -134,6 +134,10 @@ export type StorageAccessType = { paymentAddress: string; zoomFactor: ZoomFactorType; preferredLeftPaneWidth: number; + areWeASubscriber: boolean; + subscriberId: Uint8Array; + subscriberCurrencyCode: string; + displayBadgesOnProfile: boolean; // Deprecated senderCertificateWithUuid: never;