Hide "become a sustainer" button if you're already a sustainer

This commit is contained in:
Evan Hahn 2021-11-30 10:29:57 -06:00 committed by GitHub
parent 7edf3763a8
commit 67b17ec317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 318 additions and 26 deletions

View File

@ -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;

View File

@ -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;
}

View File

@ -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<void> {
onlineEventTarget: window,
storage: window.storage,
});
areWeASubscriberService.update(window.storage, server);
});
const eventHandlerQueue = new window.PQueue({
@ -3477,6 +3480,11 @@ export async function startApp(): Promise<void> {
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}`);
}

View File

@ -18,6 +18,7 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/BadgeDialog', module);
const defaultProps: ComponentProps<typeof BadgeDialog> = {
areWeASubscriber: false,
badges: getFakeBadges(3),
firstName: 'Alice',
i18n,
@ -95,3 +96,7 @@ story.add('Five badges', () => (
story.add('Many badges', () => (
<BadgeDialog {...defaultProps} badges={getFakeBadges(50)} />
));
story.add('Many badges, user is a subscriber', () => (
<BadgeDialog {...defaultProps} areWeASubscriber badges={getFakeBadges(50)} />
));

View File

@ -16,6 +16,7 @@ import { BadgeCarouselIndex } from './BadgeCarouselIndex';
import { BadgeSustainerInstructionsDialog } from './BadgeSustainerInstructionsDialog';
type PropsType = Readonly<{
areWeASubscriber: boolean;
badges: ReadonlyArray<BadgeType>;
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}
/>
</div>
<Button
className={classNames(
'BadgeDialog__instructions-button',
currentBadge.category !== BadgeCategory.Donor &&
'BadgeDialog__instructions-button--hidden'
)}
onClick={onShowInstructions}
size={ButtonSize.Large}
>
{i18n('BadgeDialog__become-a-sustainer-button')}
</Button>
{!areWeASubscriber && (
<Button
className={classNames(
'BadgeDialog__instructions-button',
currentBadge.category !== BadgeCategory.Donor &&
'BadgeDialog__instructions-button--hidden'
)}
onClick={onShowInstructions}
size={ButtonSize.Large}
>
{i18n('BadgeDialog__become-a-sustainer-button')}
</Button>
)}
<BadgeCarouselIndex
currentIndex={currentBadgeIndex}
totalCount={badges.length}

View File

@ -29,6 +29,7 @@ const defaultContact: ConversationType = getDefaultConversation({
});
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeASubscriber: false,
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
badges: overrideProps.badges || [],
contact: overrideProps.contact || defaultContact,

View File

@ -16,6 +16,7 @@ import { SharedGroupNames } from '../SharedGroupNames';
import { ConfirmationDialog } from '../ConfirmationDialog';
export type PropsDataType = {
areWeASubscriber: boolean;
areWeAdmin: boolean;
badges: ReadonlyArray<BadgeType>;
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 (
<BadgeDialog
areWeASubscriber={areWeASubscriber}
badges={badges}
firstName={contact.firstName}
i18n={i18n}

View File

@ -37,6 +37,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
addMembers: async () => {
action('addMembers');
},
areWeASubscriber: false,
canEditGroupInfo: false,
candidateContactsToAdd: times(10, () => getDefaultConversation()),
conversation: expireTimer

View File

@ -55,6 +55,7 @@ enum ModalState {
export type StateProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>;
canEditGroupInfo: boolean;
candidateContactsToAdd: Array<ConversationType>;
@ -109,6 +110,7 @@ export type Props = StateProps & ActionProps;
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
areWeASubscriber,
badges,
canEditGroupInfo,
candidateContactsToAdd,
@ -316,6 +318,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
)}
<ConversationDetailsHeader
areWeASubscriber={areWeASubscriber}
badges={badges}
canEdit={canEditGroupInfo}
conversation={conversation}

View File

@ -41,6 +41,7 @@ const Wrapper = (overrideProps: Partial<Props>) => {
return (
<ConversationDetailsHeader
areWeASubscriber={false}
conversation={createConversation()}
i18n={i18n}
canEdit={false}

View File

@ -17,6 +17,7 @@ import { BadgeDialog } from '../../BadgeDialog';
import type { BadgeType } from '../../../badges/types';
export type Props = {
areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>;
canEdit: boolean;
conversation: ConversationType;
@ -36,6 +37,7 @@ enum ConversationDetailsHeaderActiveModal {
const bem = bemGenerator('ConversationDetails-header');
export const ConversationDetailsHeader: React.ComponentType<Props> = ({
areWeASubscriber,
badges,
canEdit,
conversation,
@ -128,6 +130,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
case ConversationDetailsHeaderActiveModal.ShowingBadges:
modal = (
<BadgeDialog
areWeASubscriber={areWeASubscriber}
badges={badges || []}
firstName={conversation.firstName}
i18n={i18n}

View File

@ -0,0 +1,36 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { StorageInterface } from '../types/Storage.d';
import type { WebAPIType } from '../textsecure/WebAPI';
import { LatestQueue } from '../util/LatestQueue';
import { waitForOnline } from '../util/waitForOnline';
// This is only exported for testing.
export class AreWeASubscriberService {
private readonly queue = new LatestQueue();
update(
storage: Pick<StorageInterface, 'get' | 'put' | 'onready'>,
server: Pick<WebAPIType, 'getHasSubscription'>
): void {
this.queue.add(async () => {
await new Promise<void>(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();

View File

@ -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) {

View File

@ -772,6 +772,7 @@ async function removeAllSignedPreKeys() {
const ITEM_KEYS: Partial<Record<ItemKeyType, Array<string>>> = {
senderCertificate: ['value.serialized'],
senderCertificateNoE164: ['value.serialized'],
subscriberId: ['value'],
profileKey: ['value'],
};
async function createOrUpdateItem<K extends ItemKeyType>(data: ItemType<K>) {

View File

@ -36,6 +36,8 @@ export type ItemsStateType = {
readonly preferredLeftPaneWidth?: number;
readonly preferredReactionEmoji?: Array<string>;
readonly areWeASubscriber?: boolean;
};
// Actions

View File

@ -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<ItemsStateType>): boolean =>
Boolean(areWeASubscriber)
);
export const getUserAgent = createSelector(
getItems,
(state: ItemsStateType): string => state.userAgent as string

View File

@ -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,

View File

@ -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,

View File

@ -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', () => {
[

View File

@ -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<void>();
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);
});
});

View File

@ -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<Uint8Array>;
getAvatar: (path: string) => Promise<Uint8Array>;
getDevices: () => Promise<GetDevicesResultType>;
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
getGroup: (options: GroupCredentialsType) => Promise<Proto.Group>;
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<boolean> {
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<WebSocketResource> {

View File

@ -134,6 +134,10 @@ export type StorageAccessType = {
paymentAddress: string;
zoomFactor: ZoomFactorType;
preferredLeftPaneWidth: number;
areWeASubscriber: boolean;
subscriberId: Uint8Array;
subscriberCurrencyCode: string;
displayBadgesOnProfile: boolean;
// Deprecated
senderCertificateWithUuid: never;