From f647c4e053a1044b7eff84690e000d145bd38530 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 2 Nov 2021 18:01:13 -0500 Subject: [PATCH] Display user badges --- app/attachments.ts | 32 +++ app/main.ts | 6 + fixtures/blue-heart.svg | 1 + fixtures/orange-heart.svg | 1 + js/modules/signal.js | 7 + preload.js | 1 + stylesheets/components/Avatar.scss | 7 + .../components/BadgeCarouselIndex.scss | 35 +++ stylesheets/components/BadgeDialog.scss | 111 +++++++ stylesheets/manifest.scss | 2 + ts/background.ts | 17 ++ ts/badges/BadgeCategory.ts | 15 + ts/badges/BadgeImageTheme.ts | 15 + ts/badges/badgeImageFileDownloader.ts | 101 +++++++ ts/badges/getBadgeImageFileLocalPath.ts | 33 +++ ts/badges/isBadgeImageFileUrlValid.ts | 12 + ts/badges/parseBadgesFromServer.ts | 123 ++++++++ ts/badges/types.ts | 30 ++ .../AnnouncementsOnlyGroupBanner.tsx | 5 +- ts/components/Avatar.stories.tsx | 24 ++ ts/components/Avatar.tsx | 36 +++ ts/components/BadgeCarouselIndex.tsx | 40 +++ ts/components/BadgeDescription.stories.tsx | 24 ++ ts/components/BadgeDescription.tsx | 42 +++ ts/components/BadgeDialog.stories.tsx | 97 +++++++ ts/components/BadgeDialog.tsx | 109 +++++++ ts/components/BadgeImage.tsx | 48 ++++ ts/components/CompositionArea.stories.tsx | 2 + ts/components/CompositionArea.tsx | 4 + ts/components/ConversationList.stories.tsx | 255 ++++++++-------- ts/components/ConversationList.tsx | 20 +- ts/components/ForwardMessageModal.stories.tsx | 16 +- ts/components/ForwardMessageModal.tsx | 5 +- ts/components/LeftPane.stories.tsx | 65 +++-- ts/components/LeftPane.tsx | 9 +- ts/components/Lightbox.stories.tsx | 1 + .../conversation/ContactModal.stories.tsx | 10 + ts/components/conversation/ContactModal.tsx | 272 ++++++++++-------- .../ConversationHeader.stories.tsx | 16 +- .../conversation/ConversationHeader.tsx | 9 +- .../conversation/ConversationHero.stories.tsx | 40 +-- .../conversation/ConversationHero.tsx | 7 +- .../conversation/Timeline.stories.tsx | 44 +-- .../AddGroupMembersModal.stories.tsx | 2 + .../AddGroupMembersModal.tsx | 5 +- .../ChooseGroupMembersModal.tsx | 5 +- .../ConversationDetails.stories.tsx | 3 + .../ConversationDetails.tsx | 14 +- .../ConversationDetailsHeader.stories.tsx | 81 +++--- .../ConversationDetailsHeader.tsx | 77 +++-- ...versationDetailsMembershipList.stories.tsx | 16 ++ .../ConversationDetailsMembershipList.tsx | 11 +- .../PendingInvites.stories.tsx | 1 + .../BaseConversationListItem.tsx | 9 +- .../conversationList/ConversationListItem.tsx | 13 +- ts/model-types.d.ts | 8 + ts/models/conversations.ts | 1 + ts/sql/Client.ts | 26 ++ ts/sql/Interface.ts | 6 + ts/sql/Server.ts | 145 ++++++++++ ts/sql/migrations/44-badges.ts | 43 +++ ts/sql/migrations/index.ts | 2 + ts/state/actions.ts | 5 +- ts/state/ducks/accounts.ts | 8 +- ts/state/ducks/badges.ts | 157 ++++++++++ ts/state/ducks/conversations.ts | 11 + ts/state/reducer.ts | 2 + ts/state/selectors/badges.ts | 71 +++++ ts/state/selectors/conversations.ts | 1 + ts/state/smart/CompositionArea.tsx | 3 +- ts/state/smart/ContactModal.tsx | 4 +- ts/state/smart/ConversationDetails.tsx | 28 +- ts/state/smart/ConversationHeader.tsx | 5 +- ts/state/smart/ForwardMessageModal.tsx | 3 +- ts/state/smart/HeroRow.tsx | 7 +- ts/state/smart/LeftPane.tsx | 5 +- ts/state/types.ts | 2 + .../badges/getBadgeImageFileLocalPath_test.ts | 128 +++++++++ .../badges/isBadgeImageFileUrlValid_test.ts | 38 +++ .../badges/parseBadgesFromServer_test.ts | 212 ++++++++++++++ .../helpers/getDefaultConversation.ts | 1 + ts/test-both/helpers/getFakeBadge.ts | 42 +++ ts/test-both/state/ducks/badges_test.ts | 114 ++++++++ ts/test-both/util/userLanguages_test.ts | 77 +++++ .../state/selectors/messages_test.ts | 2 + .../util/encryptProfileData_test.ts | 1 + ts/test-electron/windows/attachments_test.ts | 6 + ts/textsecure/SendMessage.ts | 3 +- ts/textsecure/WebAPI.ts | 58 +++- ts/util/attachments.ts | 2 + ts/util/findAndFormatContact.ts | 3 + ts/util/getProfile.ts | 33 +++ ts/util/userLanguages.ts | 53 ++++ ts/window.d.ts | 2 + ts/windows/attachments.ts | 11 +- 95 files changed, 2891 insertions(+), 424 deletions(-) create mode 100644 fixtures/blue-heart.svg create mode 100644 fixtures/orange-heart.svg create mode 100644 stylesheets/components/BadgeCarouselIndex.scss create mode 100644 stylesheets/components/BadgeDialog.scss create mode 100644 ts/badges/BadgeCategory.ts create mode 100644 ts/badges/BadgeImageTheme.ts create mode 100644 ts/badges/badgeImageFileDownloader.ts create mode 100644 ts/badges/getBadgeImageFileLocalPath.ts create mode 100644 ts/badges/isBadgeImageFileUrlValid.ts create mode 100644 ts/badges/parseBadgesFromServer.ts create mode 100644 ts/badges/types.ts create mode 100644 ts/components/BadgeCarouselIndex.tsx create mode 100644 ts/components/BadgeDescription.stories.tsx create mode 100644 ts/components/BadgeDescription.tsx create mode 100644 ts/components/BadgeDialog.stories.tsx create mode 100644 ts/components/BadgeDialog.tsx create mode 100644 ts/components/BadgeImage.tsx create mode 100644 ts/sql/migrations/44-badges.ts create mode 100644 ts/state/ducks/badges.ts create mode 100644 ts/state/selectors/badges.ts create mode 100644 ts/test-both/badges/getBadgeImageFileLocalPath_test.ts create mode 100644 ts/test-both/badges/isBadgeImageFileUrlValid_test.ts create mode 100644 ts/test-both/badges/parseBadgesFromServer_test.ts create mode 100644 ts/test-both/helpers/getFakeBadge.ts create mode 100644 ts/test-both/state/ducks/badges_test.ts create mode 100644 ts/test-both/util/userLanguages_test.ts create mode 100644 ts/util/userLanguages.ts diff --git a/app/attachments.ts b/app/attachments.ts index 322182b85..0126bdcfa 100644 --- a/app/attachments.ts +++ b/app/attachments.ts @@ -13,6 +13,7 @@ import normalizePath from 'normalize-path'; import { getPath, getStickersPath, + getBadgesPath, getDraftPath, getTempPath, createDeleter, @@ -30,6 +31,16 @@ export const getAllAttachments = async ( return map(files, file => relative(dir, file)); }; +const getAllBadgeImageFiles = async ( + userDataPath: string +): Promise> => { + const dir = getBadgesPath(userDataPath); + const pattern = normalizePath(join(dir, '**', '*')); + + const files = await fastGlob(pattern, { onlyFiles: true }); + return map(files, file => relative(dir, file)); +}; + export const getAllStickers = async ( userDataPath: string ): Promise> => { @@ -101,6 +112,27 @@ export const deleteAllStickers = async ({ console.log(`deleteAllStickers: deleted ${stickers.length} files`); }; +export const deleteAllBadges = async ({ + userDataPath, + pathsToKeep, +}: { + userDataPath: string; + pathsToKeep: Set; +}): Promise => { + const deleteFromDisk = createDeleter(getBadgesPath(userDataPath)); + + let filesDeleted = 0; + for (const file of await getAllBadgeImageFiles(userDataPath)) { + if (!pathsToKeep.has(file)) { + // eslint-disable-next-line no-await-in-loop + await deleteFromDisk(file); + filesDeleted += 1; + } + } + + console.log(`deleteAllBadges: deleted ${filesDeleted} files`); +}; + export const deleteAllDraftAttachments = async ({ userDataPath, attachments, diff --git a/app/main.ts b/app/main.ts index 97ff5f77a..3c76bb76c 100644 --- a/app/main.ts +++ b/app/main.ts @@ -292,6 +292,7 @@ function prepareUrl( buildExpiration: config.get('buildExpiration'), serverUrl: config.get('serverUrl'), storageUrl: config.get('storageUrl'), + updatesUrl: config.get('updatesUrl'), directoryUrl: config.get('directoryUrl'), directoryEnclaveId: config.get('directoryEnclaveId'), directoryTrustAnchor: config.get('directoryTrustAnchor'), @@ -1557,6 +1558,11 @@ app.on('ready', async () => { attachments: orphanedAttachments, }); + await attachments.deleteAllBadges({ + userDataPath, + pathsToKeep: await sql.sqlCall('getAllBadgeImageFileLocalPaths', []), + }); + const allStickers = await attachments.getAllStickers(userDataPath); const orphanedStickers = await sql.sqlCall('removeKnownStickers', [ allStickers, diff --git a/fixtures/blue-heart.svg b/fixtures/blue-heart.svg new file mode 100644 index 000000000..0535e2d85 --- /dev/null +++ b/fixtures/blue-heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fixtures/orange-heart.svg b/fixtures/orange-heart.svg new file mode 100644 index 000000000..164678baa --- /dev/null +++ b/fixtures/orange-heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/signal.js b/js/modules/signal.js index b1d42bd83..3fa5bc4d0 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -175,6 +175,7 @@ function initializeMigrations({ getDraftPath, getPath, getStickersPath, + getBadgesPath, getTempPath, openFileInFolder, saveAttachmentToDisk, @@ -207,6 +208,10 @@ function initializeMigrations({ const deleteSticker = Attachments.createDeleter(stickersPath); const readStickerData = createReader(stickersPath); + const badgesPath = getBadgesPath(userDataPath); + const getAbsoluteBadgeImageFilePath = createAbsolutePathGetter(badgesPath); + const writeNewBadgeImageFileData = createWriterForNew(badgesPath, '.svg'); + const tempPath = getTempPath(userDataPath); const getAbsoluteTempPath = createAbsolutePathGetter(tempPath); const writeNewTempData = createWriterForNew(tempPath); @@ -243,6 +248,7 @@ function initializeMigrations({ doesAttachmentExist, getAbsoluteAttachmentPath, getAbsoluteAvatarPath, + getAbsoluteBadgeImageFilePath, getAbsoluteDraftPath, getAbsoluteStickerPath, getAbsoluteTempPath, @@ -305,6 +311,7 @@ function initializeMigrations({ writeNewAttachmentData: createWriterForNew(attachmentsPath), writeNewAvatarData, writeNewDraftData, + writeNewBadgeImageFileData, }; } diff --git a/preload.js b/preload.js index cfb0a611c..635296370 100644 --- a/preload.js +++ b/preload.js @@ -363,6 +363,7 @@ try { window.WebAPI = window.textsecure.WebAPI.initialize({ url: config.serverUrl, storageUrl: config.storageUrl, + updatesUrl: config.updatesUrl, directoryUrl: config.directoryUrl, directoryEnclaveId: config.directoryEnclaveId, directoryTrustAnchor: config.directoryTrustAnchor, diff --git a/stylesheets/components/Avatar.scss b/stylesheets/components/Avatar.scss index c572f408a..166d06310 100644 --- a/stylesheets/components/Avatar.scss +++ b/stylesheets/components/Avatar.scss @@ -105,4 +105,11 @@ &__spinner-container { padding: 4px; } + + &__badge { + bottom: 0; + position: absolute; + right: 0; + z-index: 1; + } } diff --git a/stylesheets/components/BadgeCarouselIndex.scss b/stylesheets/components/BadgeCarouselIndex.scss new file mode 100644 index 000000000..c4c1cb964 --- /dev/null +++ b/stylesheets/components/BadgeCarouselIndex.scss @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.BadgeCarouselIndex { + display: flex; + flex-wrap: wrap; + justify-content: center; + row-gap: 10px; + column-gap: 8px; + + &__dot { + border-radius: 100%; + height: 8px; + width: 8px; + margin-top: 8px; + + @include light-theme { + background: $color-black-alpha-20; + } + + @include dark-theme { + background: $color-white-alpha-20; + } + + &--selected { + @include light-theme { + background: $color-ultramarine; + } + + @include dark-theme { + background: $color-ultramarine-light; + } + } + } +} diff --git a/stylesheets/components/BadgeDialog.scss b/stylesheets/components/BadgeDialog.scss new file mode 100644 index 000000000..28caf429f --- /dev/null +++ b/stylesheets/components/BadgeDialog.scss @@ -0,0 +1,111 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.BadgeDialog { + @mixin fixed-height($height) { + height: $height; + overflow-y: auto; + } + + user-select: none; + + // We use this selector for specificity. + &.module-Modal { + max-width: 420px; + } + + &__body { + display: flex; + align-items: center; + } + + &__nav { + $light-color: $color-gray-65; + $dark-color: $color-gray-05; + + @include button-reset; + align-items: center; + border-radius: 4px; + display: flex; + justify-content: center; + padding: 3px 0; + + &[disabled] { + visibility: hidden; + } + + &::before { + content: ''; + display: block; + width: 20px; + height: 20px; + } + + @include light-theme { + &:hover, + &:focus { + background: $color-gray-02; + } + &:active { + background: $color-gray-05; + } + } + @include dark-theme { + &:hover, + &:focus { + background: $color-gray-80; + } + &:active { + background: $color-gray-75; + } + } + + &--previous::before { + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $light-color + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $dark-color + ); + } + } + + &--next::before { + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-right-24.svg', + $light-color + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-right-24.svg', + $dark-color + ); + } + } + } + + &__main { + flex-grow: 1; + text-align: center; + padding: 24px 10px; + } + + &__name { + @include font-title-2; + @include fixed-height(2.5rem); + margin-top: 24px; + margin-bottom: 8px; + } + + &__description { + @include font-body-1; + @include fixed-height(3.5rem); + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 57f566f82..78b8e2910 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -32,6 +32,8 @@ @import './components/AvatarModalButtons.scss'; @import './components/AvatarPreview.scss'; @import './components/AvatarTextEditor.scss'; +@import './components/BadgeCarouselIndex.scss'; +@import './components/BadgeDialog.scss'; @import './components/BetterAvatarBubble.scss'; @import './components/Button.scss'; @import './components/CallingLobby.scss'; diff --git a/ts/background.ts b/ts/background.ts index 16d70a242..8cd3ed57f 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -88,6 +88,8 @@ import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff'; import { handleMessageSend } from './util/handleMessageSend'; import { AppViewType } from './state/ducks/app'; import { UsernameSaveState } from './state/ducks/conversationsEnums'; +import type { BadgesStateType } from './state/ducks/badges'; +import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader'; import { isIncoming } from './state/selectors/message'; import { actionCreators } from './state/actions'; import { Deletes } from './messageModifiers/Deletes'; @@ -165,6 +167,16 @@ export async function startApp(): Promise { await window.Signal.Util.initializeMessageCounter(); + let initialBadgesState: BadgesStateType = { byId: {} }; + async function loadInitialBadgesState(): Promise { + initialBadgesState = { + byId: window.Signal.Util.makeLookup( + await window.Signal.Data.getAllBadges(), + 'id' + ), + }; + } + // Initialize WebAPI as early as possible let server: WebAPIType | undefined; let messageReceiver: MessageReceiver | undefined; @@ -888,6 +900,7 @@ export async function startApp(): Promise { window.ConversationController.load(), Stickers.load(), loadRecentEmojis(), + loadInitialBadgesState(), window.textsecure.storage.protocol.hydrateCaches(), ]); await window.ConversationController.checkForConflicts(); @@ -929,6 +942,7 @@ export async function startApp(): Promise { const theme = themeSetting === 'system' ? window.systemTheme : themeSetting; const initialState = { + badges: initialBadgesState, conversations: { conversationLookup: window.Signal.Util.makeLookup(conversations, 'id'), conversationsByE164: window.Signal.Util.makeLookup( @@ -989,6 +1003,7 @@ export async function startApp(): Promise { actionCreators.audioRecorder, store.dispatch ), + badges: bindActionCreators(actionCreators.badges, store.dispatch), calling: bindActionCreators(actionCreators.calling, store.dispatch), composer: bindActionCreators(actionCreators.composer, store.dispatch), conversations: bindActionCreators( @@ -1691,6 +1706,8 @@ export async function startApp(): Promise { window.dispatchEvent(new Event('storage_ready')); + badgeImageFileDownloader.checkForFilesToDownload(); + log.info('Expiration start timestamp cleanup: starting...'); const messagesUnexpectedlyMissingExpirationStartTimestamp = await window.Signal.Data.getMessagesUnexpectedlyMissingExpirationStartTimestamp(); log.info( diff --git a/ts/badges/BadgeCategory.ts b/ts/badges/BadgeCategory.ts new file mode 100644 index 000000000..8ba5810b0 --- /dev/null +++ b/ts/badges/BadgeCategory.ts @@ -0,0 +1,15 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { makeEnumParser } from '../util/enum'; + +// The server may return "testing", which we should parse as "other". +export enum BadgeCategory { + Donor = 'donor', + Other = 'other', +} + +export const parseBadgeCategory = makeEnumParser( + BadgeCategory, + BadgeCategory.Other +); diff --git a/ts/badges/BadgeImageTheme.ts b/ts/badges/BadgeImageTheme.ts new file mode 100644 index 000000000..5b4027197 --- /dev/null +++ b/ts/badges/BadgeImageTheme.ts @@ -0,0 +1,15 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { makeEnumParser } from '../util/enum'; + +export enum BadgeImageTheme { + Light = 'light', + Dark = 'dark', + Transparent = 'transparent', +} + +export const parseBadgeImageTheme = makeEnumParser( + BadgeImageTheme, + BadgeImageTheme.Transparent +); diff --git a/ts/badges/badgeImageFileDownloader.ts b/ts/badges/badgeImageFileDownloader.ts new file mode 100644 index 000000000..d847e7c0a --- /dev/null +++ b/ts/badges/badgeImageFileDownloader.ts @@ -0,0 +1,101 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import PQueue from 'p-queue'; +import * as log from '../logging/log'; +import { MINUTE } from '../util/durations'; +import { missingCaseError } from '../util/missingCaseError'; +import { waitForOnline } from '../util/waitForOnline'; + +enum BadgeDownloaderState { + Idle, + Checking, + CheckingWithAnotherCheckEnqueued, +} + +class BadgeImageFileDownloader { + private state = BadgeDownloaderState.Idle; + + private queue = new PQueue({ concurrency: 3 }); + + public async checkForFilesToDownload(): Promise { + switch (this.state) { + case BadgeDownloaderState.CheckingWithAnotherCheckEnqueued: + log.info( + 'BadgeDownloader#checkForFilesToDownload: not enqueuing another check' + ); + return; + case BadgeDownloaderState.Checking: + log.info( + 'BadgeDownloader#checkForFilesToDownload: enqueuing another check' + ); + this.state = BadgeDownloaderState.CheckingWithAnotherCheckEnqueued; + return; + case BadgeDownloaderState.Idle: { + this.state = BadgeDownloaderState.Checking; + + const urlsToDownload = getUrlsToDownload(); + log.info( + `BadgeDownloader#checkForFilesToDownload: downloading ${urlsToDownload.length} badge(s)` + ); + + try { + await this.queue.addAll( + urlsToDownload.map(url => () => downloadBadgeImageFile(url)) + ); + } catch (err: unknown) { + // Errors are ignored. + } + + // Without this cast, TypeScript has an incorrect type for this value, assuming + // it's a constant when it could've changed. This is a [long-standing TypeScript + // issue][0]. + // + // [0]: https://github.com/microsoft/TypeScript/issues/9998 + const previousState = this.state as BadgeDownloaderState; + this.state = BadgeDownloaderState.Idle; + if ( + previousState === + BadgeDownloaderState.CheckingWithAnotherCheckEnqueued + ) { + this.checkForFilesToDownload(); + } + return; + } + default: + throw missingCaseError(this.state); + } + } +} + +export const badgeImageFileDownloader = new BadgeImageFileDownloader(); + +function getUrlsToDownload(): Array { + const result: Array = []; + const badges = Object.values(window.reduxStore.getState().badges.byId); + for (const badge of badges) { + for (const image of badge.images) { + for (const imageFile of Object.values(image)) { + if (!imageFile.localPath) { + result.push(imageFile.url); + } + } + } + } + return result; +} + +async function downloadBadgeImageFile(url: string): Promise { + await waitForOnline(navigator, window, { timeout: 1 * MINUTE }); + + const imageFileData = await window.textsecure.server.getBadgeImageFile(url); + const localPath = await window.Signal.Migrations.writeNewBadgeImageFileData( + imageFileData + ); + + await window.Signal.Data.badgeImageFileDownloaded(url, localPath); + + window.reduxActions.badges.badgeImageFileDownloaded(url, localPath); + + return localPath; +} diff --git a/ts/badges/getBadgeImageFileLocalPath.ts b/ts/badges/getBadgeImageFileLocalPath.ts new file mode 100644 index 000000000..44a63a42b --- /dev/null +++ b/ts/badges/getBadgeImageFileLocalPath.ts @@ -0,0 +1,33 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { first, last } from 'lodash'; +import type { BadgeType, BadgeImageType } from './types'; +import type { BadgeImageTheme } from './BadgeImageTheme'; + +export function getBadgeImageFileLocalPath( + badge: Readonly, + size: number, + theme: BadgeImageTheme +): undefined | string { + if (!badge) { + return undefined; + } + + const { images } = badge; + + // We expect this to be defined for valid input, but defend against unexpected array + // lengths. + let idealImage: undefined | BadgeImageType; + if (size < 24) { + idealImage = first(images); + } else if (size < 36) { + idealImage = images[1] || first(images); + } else if (size < 160) { + idealImage = images[2] || first(images); + } else { + idealImage = last(images); + } + + return idealImage?.[theme]?.localPath; +} diff --git a/ts/badges/isBadgeImageFileUrlValid.ts b/ts/badges/isBadgeImageFileUrlValid.ts new file mode 100644 index 000000000..e905d4c94 --- /dev/null +++ b/ts/badges/isBadgeImageFileUrlValid.ts @@ -0,0 +1,12 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { maybeParseUrl } from '../util/url'; + +export function isBadgeImageFileUrlValid( + url: string, + updatesUrl: string +): boolean { + const expectedPrefix = new URL('/static/badges', updatesUrl).href; + return url.startsWith(expectedPrefix) && Boolean(maybeParseUrl(url)); +} diff --git a/ts/badges/parseBadgesFromServer.ts b/ts/badges/parseBadgesFromServer.ts new file mode 100644 index 000000000..dc62df792 --- /dev/null +++ b/ts/badges/parseBadgesFromServer.ts @@ -0,0 +1,123 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as z from 'zod'; +import { isEmpty } from 'lodash'; +import { isRecord } from '../util/isRecord'; +import { isNormalNumber } from '../util/isNormalNumber'; +import * as log from '../logging/log'; +import type { BadgeType, BadgeImageType } from './types'; +import { parseBadgeCategory } from './BadgeCategory'; +import { BadgeImageTheme, parseBadgeImageTheme } from './BadgeImageTheme'; + +const MAX_BADGES = 1000; + +const badgeFromServerSchema = z.object({ + category: z.string(), + description: z.string(), + id: z.string(), + name: z.string(), + svg: z.string(), + svgs: z.array(z.record(z.string())).length(3), + expiration: z.number().optional(), + visible: z.boolean().optional(), +}); + +export function parseBadgesFromServer( + value: unknown, + updatesUrl: string +): Array { + if (!Array.isArray(value)) { + return []; + } + + const result: Array = []; + + const numberOfBadgesToParse = Math.min(value.length, MAX_BADGES); + for (let i = 0; i < numberOfBadgesToParse; i += 1) { + const item = value[i]; + + const parseResult = badgeFromServerSchema.safeParse(item); + if (!parseResult.success) { + log.warn( + 'parseBadgesFromServer got an invalid item', + parseResult.error.format() + ); + continue; + } + + const { + category, + description: descriptionTemplate, + expiration, + id, + name, + svg, + svgs, + visible, + } = parseResult.data; + const images = parseImages(svgs, svg, updatesUrl); + if (images.length !== 4) { + log.warn('Got invalid number of SVGs from the server'); + continue; + } + + result.push({ + id, + category: parseBadgeCategory(category), + name, + descriptionTemplate, + images, + ...(isNormalNumber(expiration) && typeof visible === 'boolean' + ? { + expiresAt: expiration * 1000, + isVisible: visible, + } + : {}), + }); + } + + return result; +} + +const parseImages = ( + rawSvgs: ReadonlyArray>, + rawSvg: string, + updatesUrl: string +): Array => { + const result: Array = []; + + for (const item of rawSvgs) { + if (!isRecord(item)) { + log.warn('Got invalid SVG from the server'); + continue; + } + + const image: BadgeImageType = {}; + for (const [rawTheme, filename] of Object.entries(item)) { + if (typeof filename !== 'string') { + log.warn('Got an SVG from the server that lacked a valid filename'); + continue; + } + const theme = parseBadgeImageTheme(rawTheme); + image[theme] = { url: parseImageFilename(filename, updatesUrl) }; + } + + if (isEmpty(image)) { + log.warn('Got an SVG from the server that lacked valid values'); + } else { + result.push(image); + } + } + + result.push({ + [BadgeImageTheme.Transparent]: { + url: parseImageFilename(rawSvg, updatesUrl), + }, + }); + + return result; +}; + +const parseImageFilename = (filename: string, updatesUrl: string): string => + new URL(`/static/badges/${filename}`, updatesUrl).toString(); diff --git a/ts/badges/types.ts b/ts/badges/types.ts new file mode 100644 index 000000000..c9cf67d7e --- /dev/null +++ b/ts/badges/types.ts @@ -0,0 +1,30 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { BadgeCategory } from './BadgeCategory'; +import type { BadgeImageTheme } from './BadgeImageTheme'; + +type SomeoneElsesBadgeType = Readonly<{ + category: BadgeCategory; + descriptionTemplate: string; + id: string; + images: ReadonlyArray; + name: string; +}>; + +type OurBadgeType = SomeoneElsesBadgeType & + Readonly<{ + expiresAt: number; + isVisible: boolean; + }>; + +export type BadgeType = SomeoneElsesBadgeType | OurBadgeType; + +export type BadgeImageType = Partial< + Record +>; + +export type BadgeImageFileType = { + localPath?: string; + url: string; +}; diff --git a/ts/components/AnnouncementsOnlyGroupBanner.tsx b/ts/components/AnnouncementsOnlyGroupBanner.tsx index bbe01d4a8..976f9b5d6 100644 --- a/ts/components/AnnouncementsOnlyGroupBanner.tsx +++ b/ts/components/AnnouncementsOnlyGroupBanner.tsx @@ -4,7 +4,7 @@ import React, { useState } from 'react'; import type { ConversationType } from '../state/ducks/conversations'; import { Intl } from './Intl'; -import type { LocalizerType } from '../types/Util'; +import type { LocalizerType, ThemeType } from '../types/Util'; import { Modal } from './Modal'; import { ConversationListItem } from './conversationList/ConversationListItem'; @@ -12,12 +12,14 @@ type PropsType = { groupAdmins: Array; i18n: LocalizerType; openConversation: (conversationId: string) => unknown; + theme: ThemeType; }; export const AnnouncementsOnlyGroupBanner = ({ groupAdmins, i18n, openConversation, + theme, }: PropsType): JSX.Element => { const [isShowingAdmins, setIsShowingAdmins] = useState(false); @@ -40,6 +42,7 @@ export const AnnouncementsOnlyGroupBanner = ({ lastMessage={undefined} lastUpdated={undefined} typingContact={undefined} + theme={theme} /> ))} diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index fc779c49e..b8af99c28 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -14,6 +14,8 @@ import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; import type { AvatarColorType } from '../types/Colors'; import { AvatarColors } from '../types/Colors'; +import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; +import { getFakeBadge } from '../test-both/helpers/getFakeBadge'; const i18n = setupI18n('en', enMessages); @@ -37,6 +39,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ ? overrideProps.acceptedMessageRequest : true, avatarPath: text('avatarPath', overrideProps.avatarPath || ''), + badge: overrideProps.badge, blur: overrideProps.blur, color: select('color', colorMap, overrideProps.color || AvatarColors[0]), conversationType: select( @@ -66,6 +69,27 @@ story.add('Avatar', () => { return sizes.map(size => ); }); +story.add('With badge', () => { + const Wrapper = () => { + const theme = React.useContext(StorybookThemeContext); + const props = createProps({ + avatarPath: '/fixtures/kitten-3-64-64.jpg', + badge: getFakeBadge(), + theme, + }); + + return ( + <> + {sizes.map(size => ( + + ))} + + ); + }; + + return ; +}); + story.add('Wide image', () => { const props = createProps({ avatarPath: '/fixtures/wide.jpg', diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 078c6aad0..7a9ed648e 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -15,10 +15,14 @@ import { Spinner } from './Spinner'; import { getInitials } from '../util/getInitials'; import type { LocalizerType } from '../types/Util'; +import { ThemeType } from '../types/Util'; import type { AvatarColorType } from '../types/Colors'; +import type { BadgeType } from '../badges/types'; import * as log from '../logging/log'; import { assert } from '../util/assert'; import { shouldBlurAvatar } from '../util/shouldBlurAvatar'; +import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath'; +import { BadgeImageTheme } from '../badges/BadgeImageTheme'; export enum AvatarBlur { NoBlur, @@ -40,6 +44,7 @@ export enum AvatarSize { export type Props = { avatarPath?: string; + badge?: BadgeType; blur?: AvatarBlur; color?: AvatarColorType; loading?: boolean; @@ -53,6 +58,7 @@ export type Props = { profileName?: string; sharedGroupNames: Array; size: AvatarSize; + theme?: ThemeType; title: string; unblurredAvatarPath?: string; @@ -72,6 +78,7 @@ const getDefaultBlur = ( export const Avatar: FunctionComponent = ({ acceptedMessageRequest, avatarPath, + badge, className, color = 'A200', conversationType, @@ -83,6 +90,7 @@ export const Avatar: FunctionComponent = ({ onClick, sharedGroupNames, size, + theme, title, unblurredAvatarPath, blur = getDefaultBlur({ @@ -203,6 +211,33 @@ export const Avatar: FunctionComponent = ({ contents =
{contentsChildren}
; } + let badgeNode: ReactNode; + if (badge && theme && !isMe) { + const badgeSize = Math.ceil(size * 0.425); + const badgeTheme = + theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark; + const badgeImagePath = getBadgeImageFileLocalPath( + badge, + badgeSize, + badgeTheme + ); + if (badgeImagePath) { + badgeNode = ( + {badge.name} + ); + } + } else if (badge && !theme) { + log.error(' requires a theme if a badge is provided'); + } + return (
= ({ ref={innerRef} > {contents} + {badgeNode}
); }; diff --git a/ts/components/BadgeCarouselIndex.tsx b/ts/components/BadgeCarouselIndex.tsx new file mode 100644 index 000000000..a0948e666 --- /dev/null +++ b/ts/components/BadgeCarouselIndex.tsx @@ -0,0 +1,40 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import classNames from 'classnames'; +import { times } from 'lodash'; + +import { strictAssert } from '../util/assert'; + +export function BadgeCarouselIndex({ + currentIndex, + totalCount, +}: Readonly<{ + currentIndex: number; + totalCount: number; +}>): JSX.Element | null { + strictAssert(totalCount >= 1, 'Expected 1 or more items'); + strictAssert( + currentIndex < totalCount, + 'Expected current index to be in range' + ); + + if (totalCount < 2) { + return null; + } + + return ( +
+ {times(totalCount, index => ( +
+ ))} +
+ ); +} diff --git a/ts/components/BadgeDescription.stories.tsx b/ts/components/BadgeDescription.stories.tsx new file mode 100644 index 000000000..950fa331e --- /dev/null +++ b/ts/components/BadgeDescription.stories.tsx @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { storiesOf } from '@storybook/react'; + +import { BadgeDescription } from './BadgeDescription'; + +const story = storiesOf('Components/BadgeDescription', module); + +story.add('Normal name', () => ( + +)); + +story.add('Name with RTL overrides', () => ( + +)); diff --git a/ts/components/BadgeDescription.tsx b/ts/components/BadgeDescription.tsx new file mode 100644 index 000000000..e5343fa92 --- /dev/null +++ b/ts/components/BadgeDescription.tsx @@ -0,0 +1,42 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReactChild, ReactElement } from 'react'; +import React from 'react'; +import { ContactName } from './conversation/ContactName'; + +export function BadgeDescription({ + firstName, + template, + title, +}: Readonly<{ + firstName?: string; + template: string; + title: string; +}>): ReactElement { + const result: Array = []; + + let lastIndex = 0; + + const matches = template.matchAll(/\{short_name\}/g); + for (const match of matches) { + const matchIndex = match.index || 0; + + result.push(template.slice(lastIndex, matchIndex)); + + result.push( + + ); + + lastIndex = matchIndex + 12; + } + + result.push(template.slice(lastIndex)); + + return <>{result}; +} diff --git a/ts/components/BadgeDialog.stories.tsx b/ts/components/BadgeDialog.stories.tsx new file mode 100644 index 000000000..8b9b034a3 --- /dev/null +++ b/ts/components/BadgeDialog.stories.tsx @@ -0,0 +1,97 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ComponentProps } from 'react'; +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; +import { getFakeBadge, getFakeBadges } from '../test-both/helpers/getFakeBadge'; +import { repeat, zipObject } from '../util/iterables'; +import { BadgeImageTheme } from '../badges/BadgeImageTheme'; +import { BadgeDialog } from './BadgeDialog'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/BadgeDialog', module); + +const defaultProps: ComponentProps = { + badges: getFakeBadges(3), + firstName: 'Alice', + i18n, + onClose: action('onClose'), + title: 'Alice Levine', +}; + +story.add('No badges (closed immediately)', () => ( + +)); + +story.add('One badge', () => ( + +)); + +story.add('Badge with no image (should be impossible)', () => ( + +)); + +story.add('Badge with pending image', () => ( + +)); + +story.add('Badge with only one, low-detail image', () => ( + +)); + +story.add('Five badges', () => ( + +)); + +story.add('Many badges', () => ( + +)); diff --git a/ts/components/BadgeDialog.tsx b/ts/components/BadgeDialog.tsx new file mode 100644 index 000000000..a78e40b52 --- /dev/null +++ b/ts/components/BadgeDialog.tsx @@ -0,0 +1,109 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useEffect, useState } from 'react'; + +import { strictAssert } from '../util/assert'; +import type { LocalizerType } from '../types/Util'; +import type { BadgeType } from '../badges/types'; +import { Modal } from './Modal'; +import { BadgeDescription } from './BadgeDescription'; +import { BadgeImage } from './BadgeImage'; +import { BadgeCarouselIndex } from './BadgeCarouselIndex'; + +type PropsType = Readonly<{ + badges: ReadonlyArray; + firstName?: string; + i18n: LocalizerType; + onClose: () => unknown; + title: string; +}>; + +export function BadgeDialog(props: PropsType): null | JSX.Element { + const { badges, onClose } = props; + + const hasBadges = badges.length > 0; + useEffect(() => { + if (!hasBadges) { + onClose(); + } + }, [hasBadges, onClose]); + + return hasBadges ? : null; +} + +function BadgeDialogWithBadges({ + badges, + firstName, + i18n, + onClose, + title, +}: PropsType): JSX.Element { + const firstBadge = badges[0]; + strictAssert( + firstBadge, + ' got an empty array of badges' + ); + + const [currentBadgeId, setCurrentBadgeId] = useState(firstBadge.id); + + let currentBadge: BadgeType; + let currentBadgeIndex: number = badges.findIndex( + b => b.id === currentBadgeId + ); + if (currentBadgeIndex === -1) { + currentBadgeIndex = 0; + currentBadge = firstBadge; + } else { + currentBadge = badges[currentBadgeIndex]; + } + + const setCurrentBadgeIndex = (index: number): void => { + const newBadge = badges[index]; + strictAssert(newBadge, ' tried to select a nonexistent badge'); + setCurrentBadgeId(newBadge.id); + }; + + const navigate = (change: number): void => { + setCurrentBadgeIndex(currentBadgeIndex + change); + }; + + return ( + +
); diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 768e3ba1f..f3f4743f5 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -15,6 +15,7 @@ import { MessageSearchResult } from './conversationList/MessageSearchResult'; import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; const i18n = setupI18n('en', enMessages); @@ -79,7 +80,8 @@ const defaultModeSpecificProps = { const emptySearchResultsGroup = { isLoading: false, results: [] }; -const createProps = (overrideProps: Partial = {}): PropsType => ({ +const useProps = (overrideProps: Partial = {}): PropsType => ({ + badgesById: {}, cantAddContactToGroup: action('cantAddContactToGroup'), canResizeLeftPane: true, clearGroupCreationError: action('clearGroupCreationError'), @@ -146,6 +148,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ ), startSearch: action('startSearch'), startSettingGroupMetadata: action('startSettingGroupMetadata'), + theme: React.useContext(StorybookThemeContext), toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'), toggleConversationInChooseMembers: action( 'toggleConversationInChooseMembers' @@ -159,7 +162,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ story.add('Inbox: no conversations', () => ( ( story.add('Inbox: only pinned conversations', () => ( ( story.add('Inbox: only non-pinned conversations', () => ( ( story.add('Inbox: only archived conversations', () => ( ( story.add('Inbox: pinned and archived conversations', () => ( ( story.add('Inbox: non-pinned and archived conversations', () => ( ( story.add('Inbox: pinned and non-pinned conversations', () => ( ( )); story.add('Inbox: pinned, non-pinned, and archived conversations', () => ( - + )); // Search stories story.add('Search: no results when searching everywhere', () => ( ( story.add('Search: no results when searching everywhere (SMS)', () => ( ( story.add('Search: no results when searching in a conversation', () => ( ( story.add('Search: all results loading', () => ( ( story.add('Search: some results loading', () => ( ( story.add('Search: has conversations and contacts, but not messages', () => ( ( story.add('Search: all results', () => ( ( story.add('Archive: no archived conversations', () => ( ( story.add('Archive: archived conversations', () => ( ( story.add('Archive: searching a conversation', () => ( ( story.add('Compose: no contacts or groups', () => ( ( story.add('Compose: some contacts, no groups, no search term', () => ( ( story.add('Compose: some contacts, no groups, with a search term', () => ( ( story.add('Compose: some groups, no contacts, no search term', () => ( ( story.add('Compose: some groups, no contacts, with search term', () => ( ( story.add('Compose: some contacts, some groups, no search term', () => ( ( story.add('Compose: some contacts, some groups, with a search term', () => ( ( story.add('Captcha dialog: required', () => ( ( story.add('Captcha dialog: pending', () => ( ( story.add('Group Metadata: No Timer', () => ( ( story.add('Group Metadata: Regular Timer', () => ( ( story.add('Group Metadata: Custom Timer', () => ( ; preferredWidthFromStorage: number; selectedConversationId: undefined | string; selectedMessageId: undefined | string; @@ -90,6 +92,7 @@ export type PropsType = { canResizeLeftPane: boolean; challengeStatus: 'idle' | 'required' | 'pending'; setChallengeStatus: (status: 'idle') => void; + theme: ThemeType; // Action Creators cantAddContactToGroup: (conversationId: string) => void; @@ -143,6 +146,7 @@ export type PropsType = { }; export const LeftPane: React.FC = ({ + badgesById, cantAddContactToGroup, canResizeLeftPane, challengeStatus, @@ -182,6 +186,7 @@ export const LeftPane: React.FC = ({ startSearch, startNewConversationFromPhoneNumber, startSettingGroupMetadata, + theme, toggleComposeEditingAvatar, toggleConversationInChooseMembers, updateSearchTerm, @@ -565,6 +570,7 @@ export const LeftPane: React.FC = ({ tabIndex={-1} > = ({ startNewConversationFromPhoneNumber={ startNewConversationFromPhoneNumber } + theme={theme} /> diff --git a/ts/components/Lightbox.stories.tsx b/ts/components/Lightbox.stories.tsx index 0806c5136..b506570bb 100644 --- a/ts/components/Lightbox.stories.tsx +++ b/ts/components/Lightbox.stories.tsx @@ -280,6 +280,7 @@ story.add('Conversation Header', () => ( getConversation={() => ({ acceptedMessageRequest: true, avatarPath: '/fixtures/kitten-1-64-64.jpg', + badges: [], id: '1234', isMe: false, name: 'Test', diff --git a/ts/components/conversation/ContactModal.stories.tsx b/ts/components/conversation/ContactModal.stories.tsx index 927faa5a8..a48deeeea 100644 --- a/ts/components/conversation/ContactModal.stories.tsx +++ b/ts/components/conversation/ContactModal.stories.tsx @@ -13,6 +13,7 @@ import { ContactModal } from './ContactModal'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; import type { ConversationType } from '../../state/ducks/conversations'; +import { getFakeBadges } from '../../test-both/helpers/getFakeBadge'; const i18n = setupI18n('en', enMessages); @@ -28,6 +29,7 @@ const defaultContact: ConversationType = getDefaultConversation({ const createProps = (overrideProps: Partial = {}): PropsType => ({ areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false), + badges: overrideProps.badges || [], contact: overrideProps.contact || defaultContact, hideContactModal: action('hideContactModal'), i18n, @@ -86,3 +88,11 @@ story.add('Viewing self', () => { return ; }); + +story.add('With badges', () => { + const props = createProps({ + badges: getFakeBadges(2), + }); + + return ; +}); diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 4465e080f..146a1101f 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -3,17 +3,21 @@ import React, { useEffect, useState } from 'react'; +import { missingCaseError } from '../../util/missingCaseError'; import { About } from './About'; import { Avatar } from '../Avatar'; import { AvatarLightbox } from '../AvatarLightbox'; import type { ConversationType } from '../../state/ducks/conversations'; import { Modal } from '../Modal'; import type { LocalizerType } from '../../types/Util'; +import { BadgeDialog } from '../BadgeDialog'; +import type { BadgeType } from '../../badges/types'; import { SharedGroupNames } from '../SharedGroupNames'; import { ConfirmationDialog } from '../ConfirmationDialog'; export type PropsDataType = { areWeAdmin: boolean; + badges: ReadonlyArray; contact?: ConversationType; conversationId?: string; readonly i18n: LocalizerType; @@ -38,8 +42,15 @@ type PropsActionType = { export type PropsType = PropsDataType & PropsActionType; +enum ContactModalView { + Default, + ShowingAvatar, + ShowingBadges, +} + export const ContactModal = ({ areWeAdmin, + badges, contact, conversationId, hideContactModal, @@ -56,7 +67,7 @@ export const ContactModal = ({ throw new Error('Contact modal opened without a matching contact'); } - const [showingAvatar, setShowingAvatar] = useState(false); + const [view, setView] = useState(ContactModalView.Default); const [confirmToggleAdmin, setConfirmToggleAdmin] = useState(false); useEffect(() => { @@ -66,135 +77,158 @@ export const ContactModal = ({ } }, [conversationId, updateConversationModelSharedGroups]); - if (showingAvatar) { - return ( - setShowingAvatar(false)} - /> - ); - } + switch (view) { + case ContactModalView.Default: { + const preferredBadge: undefined | BadgeType = badges[0]; - return ( - -
- setShowingAvatar(true)} - /> -
{contact.title}
-
- -
- {contact.phoneNumber && ( -
{contact.phoneNumber}
- )} - {!contact.isMe && ( -
- +
+ -
- )} -
- - {!contact.isMe && ( - - )} - {!contact.isMe && areWeAdmin && isMember && conversationId && ( - <> + )} +
- + )} + {!contact.isMe && areWeAdmin && isMember && conversationId && ( + <> + + + + )} +
+ {confirmToggleAdmin && conversationId && ( + toggleAdmin(conversationId, contact.id), + text: isAdmin + ? i18n('ContactModal--rm-admin') + : i18n('ContactModal--make-admin'), + }, + ]} + i18n={i18n} + onClose={() => setConfirmToggleAdmin(false)} > -
-
-
- {i18n('ContactModal--remove-from-group')} - - - )} -
- {confirmToggleAdmin && conversationId && ( - toggleAdmin(conversationId, contact.id), - text: isAdmin - ? i18n('ContactModal--rm-admin') - : i18n('ContactModal--make-admin'), - }, - ]} - i18n={i18n} - onClose={() => setConfirmToggleAdmin(false)} - > - {isAdmin - ? i18n('ContactModal--rm-admin-info', [contact.title]) - : i18n('ContactModal--make-admin-info', [contact.title])} - - )} -
- - ); + {isAdmin + ? i18n('ContactModal--rm-admin-info', [contact.title]) + : i18n('ContactModal--make-admin-info', [contact.title])} + + )} +
+ + ); + } + case ContactModalView.ShowingAvatar: + return ( + setView(ContactModalView.Default)} + /> + ); + case ContactModalView.ShowingBadges: + return ( + setView(ContactModalView.Default)} + title={contact.title} + /> + ); + default: + throw missingCaseError(view); + } }; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 1ce5644b4..b812c629e 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ComponentProps } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; @@ -11,6 +11,7 @@ import { getDefaultConversation } from '../../test-both/helpers/getDefaultConver import { getRandomColor } from '../../test-both/helpers/getRandomColor'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; +import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext'; import { ConversationHeader, OutgoingCallButtonStyle, @@ -25,7 +26,7 @@ type ConversationHeaderStory = { description: string; items: Array<{ title: string; - props: ComponentProps; + props: Omit, 'theme'>; }>; }; @@ -317,15 +318,18 @@ const stories: Array = [ stories.forEach(({ title, description, items }) => book.add( title, - () => - items.map(({ title: subtitle, props }, i) => { + () => { + const theme = useContext(StorybookThemeContext); + + return items.map(({ title: subtitle, props }, i) => { return (
{subtitle ?

{subtitle}

: null} - +
); - }), + }); + }, { docs: description, } diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 6e43b9c3f..547911cb5 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -17,8 +17,9 @@ import { DisappearingTimeDialog } from '../DisappearingTimeDialog'; import { Avatar, AvatarSize } from '../Avatar'; import { InContactsIcon } from '../InContactsIcon'; -import type { LocalizerType } from '../../types/Util'; +import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; +import type { BadgeType } from '../../badges/types'; import { getMuteOptions } from '../../util/getMuteOptions'; import * as expirationTimer from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; @@ -32,11 +33,13 @@ export enum OutgoingCallButtonStyle { } export type PropsDataType = { + badge?: BadgeType; conversationTitle?: string; isMissingMandatoryProfileSharing?: boolean; outgoingCallButtonStyle: OutgoingCallButtonStyle; showBackButton?: boolean; isSMSOnly?: boolean; + theme: ThemeType; } & Pick< ConversationType, | 'acceptedMessageRequest' @@ -190,6 +193,7 @@ export class ConversationHeader extends React.Component { const { acceptedMessageRequest, avatarPath, + badge, color, i18n, type, @@ -198,6 +202,7 @@ export class ConversationHeader extends React.Component { phoneNumber, profileName, sharedGroupNames, + theme, title, unblurredAvatarPath, } = this.props; @@ -207,6 +212,7 @@ export class ConversationHeader extends React.Component { { profileName={profileName} sharedGroupNames={sharedGroupNames} size={AvatarSize.THIRTY_TWO} + theme={theme} unblurredAvatarPath={unblurredAvatarPath} /> diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index 890993e59..4c24b48bd 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -9,6 +9,7 @@ import { action } from '@storybook/addon-actions'; import { ConversationHero } from './ConversationHero'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; +import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext'; const i18n = setupI18n('en', enMessages); @@ -22,11 +23,18 @@ const getPhoneNumber = () => text('phoneNumber', '+1 (646) 327-2700'); const updateSharedGroups = action('updateSharedGroups'); +const Wrapper = ( + props: Omit, 'theme'> +) => { + const theme = React.useContext(StorybookThemeContext); + return ; +}; + storiesOf('Components/Conversation/ConversationHero', module) .add('Direct (Five Other Groups)', () => { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- { return (
- void; unblurredAvatarPath?: string; updateSharedGroups: () => unknown; + theme: ThemeType; } & Omit; const renderMembershipRow = ({ @@ -98,6 +99,7 @@ export const ConversationHero = ({ about, acceptedMessageRequest, avatarPath, + badge, color, conversationType, groupDescription, @@ -107,6 +109,7 @@ export const ConversationHero = ({ name, phoneNumber, profileName, + theme, title, onHeightChange, unblurAvatar, @@ -180,6 +183,7 @@ export const ConversationHero = ({

diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 647549c5b..d74ca07c6 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -15,6 +15,7 @@ import type { PropsType } from './Timeline'; import { Timeline } from './Timeline'; import type { TimelineItemType } from './TimelineItem'; import { TimelineItem } from './TimelineItem'; +import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext'; import { ConversationHero } from './ConversationHero'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getRandomColor } from '../../test-both/helpers/getRandomColor'; @@ -412,24 +413,31 @@ const getAvatarPath = () => text('avatarPath', '/fixtures/kitten-4-112-112.jpg'); const getPhoneNumber = () => text('phoneNumber', '+1 (808) 555-1234'); -const renderHeroRow = () => ( - -); +const renderHeroRow = () => { + const Wrapper = () => { + const theme = React.useContext(StorybookThemeContext); + return ( + + ); + }; + return ; +}; const renderLoadingRow = () => ; const renderTypingBubble = () => ( = {}): PropsType => ({ action('onMakeRequest')(conversationIds); }, requestState: RequestState.Inactive, + theme: ThemeType.light, ...overrideProps, }); diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx index e4d9d9394..7f4968478 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx @@ -5,7 +5,7 @@ import type { FunctionComponent } from 'react'; import React, { useMemo, useReducer } from 'react'; import { without } from 'lodash'; -import type { LocalizerType } from '../../../types/Util'; +import type { LocalizerType, ThemeType } from '../../../types/Util'; import { AddGroupMemberErrorDialog, AddGroupMemberErrorDialogMode, @@ -35,6 +35,7 @@ type PropsType = { makeRequest: (conversationIds: ReadonlyArray) => Promise; onClose: () => void; requestState: RequestState; + theme: ThemeType; }; enum Stage { @@ -151,6 +152,7 @@ export const AddGroupMembersModal: FunctionComponent = ({ onClose, makeRequest, requestState, + theme, }) => { const maxGroupSize = getMaximumNumberOfContacts(); const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts(); @@ -284,6 +286,7 @@ export const AddGroupMembersModal: FunctionComponent = ({ selectedContacts={selectedContacts} setCantAddContactForModal={setCantAddContactForModal} setSearchTerm={setSearchTerm} + theme={theme} toggleSelectedContact={toggleSelectedContact} /> ); diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index 156950736..883bb326a 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; import type { MeasuredComponentProps } from 'react-measure'; import Measure from 'react-measure'; -import type { LocalizerType } from '../../../../types/Util'; +import type { LocalizerType, ThemeType } from '../../../../types/Util'; import { assert } from '../../../../util/assert'; import { getOwn } from '../../../../util/getOwn'; import { refMerger } from '../../../../util/refMerger'; @@ -38,6 +38,7 @@ type PropsType = { _: Readonly ) => void; setSearchTerm: (_: string) => void; + theme: ThemeType; toggleSelectedContact: (conversationId: string) => void; }; @@ -55,6 +56,7 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ selectedContacts, setCantAddContactForModal, setSearchTerm, + theme, toggleSelectedContact, }) => { const [focusRef] = useRestoreFocus(); @@ -227,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} startNewConversationFromPhoneNumber={shouldNeverBeCalled} + theme={theme} />

); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 94c1a8bb9..be534d0fb 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -14,6 +14,7 @@ import type { Props } from './ConversationDetails'; import { ConversationDetails } from './ConversationDetails'; import type { ConversationType } from '../../../state/ducks/conversations'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { ThemeType } from '../../../types/Util'; const i18n = setupI18n('en', enMessages); @@ -55,6 +56,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ isMe: i === 2, }), })), + preferredBadgeByConversation: {}, pendingApprovalMemberships: times(8, () => ({ member: getDefaultConversation(), })), @@ -92,6 +94,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ 'onOutgoingVideoCallInConversation' ), searchInConversation: action('searchInConversation'), + theme: ThemeType.light, }); story.add('Basic', () => { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index e144074d6..3f858c9a9 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -9,8 +9,9 @@ import type { ConversationType } from '../../../state/ducks/conversations'; import { assert } from '../../../util/assert'; import { getMutedUntilText } from '../../../util/getMutedUntilText'; -import type { LocalizerType } from '../../../types/Util'; +import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; +import type { BadgeType } from '../../../badges/types'; import { CapabilityError } from '../../../types/errors'; import { missingCaseError } from '../../../util/missingCaseError'; @@ -53,6 +54,7 @@ enum ModalState { export type StateProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; + badges?: ReadonlyArray; canEditGroupInfo: boolean; candidateContactsToAdd: Array; conversation?: ConversationType; @@ -62,6 +64,7 @@ export type StateProps = { isGroup: boolean; loadRecentMediaItems: (limit: number) => void; memberships: Array; + preferredBadgeByConversation: Record; pendingApprovalMemberships: ReadonlyArray; pendingMemberships: ReadonlyArray; setDisappearingMessages: (seconds: number) => void; @@ -85,6 +88,7 @@ export type StateProps = { onBlock: () => void; onLeave: () => void; onUnblock: () => void; + theme: ThemeType; userAvatarData: Array; setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; onOutgoingAudioCallInConversation: () => unknown; @@ -104,6 +108,7 @@ export type Props = StateProps & ActionProps; export const ConversationDetails: React.ComponentType = ({ addMembers, + badges, canEditGroupInfo, candidateContactsToAdd, conversation, @@ -121,6 +126,7 @@ export const ConversationDetails: React.ComponentType = ({ onUnblock, pendingApprovalMemberships, pendingMemberships, + preferredBadgeByConversation, replaceAvatar, saveAvatarToDisk, searchInConversation, @@ -134,6 +140,7 @@ export const ConversationDetails: React.ComponentType = ({ showGroupV2Permissions, showLightboxForMedia, showPendingInvites, + theme, toggleSafetyNumberModal, updateGroupAttributes, userAvatarData, @@ -256,6 +263,7 @@ export const ConversationDetails: React.ComponentType = ({ setEditGroupAttributesRequestState(RequestState.Inactive); }} requestState={addGroupMembersRequestState} + theme={theme} /> ); break; @@ -311,6 +319,7 @@ export const ConversationDetails: React.ComponentType = ({ )} = ({ : ModalState.EditingGroupDescription ); }} + theme={theme} />
@@ -456,10 +466,12 @@ export const ConversationDetails: React.ComponentType = ({ conversationId={conversation.id} i18n={i18n} memberships={memberships} + preferredBadgeByConversation={preferredBadgeByConversation} showContactModal={showContactModal} startAddingNewMembers={() => { setModalState(ModalState.AddingGroupMembers); }} + theme={theme} /> )} diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx index 2a7c49b26..4c86fb5a9 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx @@ -8,8 +8,10 @@ import { action } from '@storybook/addon-actions'; import { number, text } from '@storybook/addon-knobs'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { getFakeBadges } from '../../../test-both/helpers/getFakeBadge'; import { setupI18n } from '../../../util/setupI18n'; import enMessages from '../../../../_locales/en/messages.json'; +import { StorybookThemeContext } from '../../../../.storybook/StorybookThemeContext'; import type { ConversationType } from '../../../state/ducks/conversations'; import type { Props } from './ConversationDetailsHeader'; @@ -34,61 +36,46 @@ const createConversation = (): ConversationType => ), }); -const createProps = (overrideProps: Partial = {}): Props => ({ - conversation: createConversation(), - i18n, - canEdit: false, - startEditing: action('startEditing'), - memberships: new Array(number('conversation members length', 0)), - isGroup: true, - isMe: false, - ...overrideProps, -}); - -story.add('Basic', () => { - const props = createProps(); - - return ; -}); - -story.add('Editable', () => { - const props = createProps({ canEdit: true }); - - return ; -}); - -story.add('Basic no-description', () => { - const props = createProps(); +const Wrapper = (overrideProps: Partial) => { + const theme = React.useContext(StorybookThemeContext); return ( ); -}); +}; -story.add('Editable no-description', () => { - const props = createProps({ canEdit: true }); +story.add('Basic', () => ); - return ( - - ); -}); +story.add('Editable', () => ); -story.add('1:1', () => ( - +story.add('Basic no-description', () => ( + )); -story.add('Note to self', () => ( - +story.add('Editable no-description', () => ( + )); + +story.add('1:1', () => ); + +story.add('Note to self', () => ); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx index 286f9eacf..ca8e4a1f3 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -11,10 +11,13 @@ import { Emojify } from '../Emojify'; import { GroupDescription } from '../GroupDescription'; import { About } from '../About'; import type { GroupV2Membership } from './ConversationDetailsMembershipList'; -import type { LocalizerType } from '../../../types/Util'; +import type { LocalizerType, ThemeType } from '../../../types/Util'; import { bemGenerator } from './util'; +import { BadgeDialog } from '../../BadgeDialog'; +import type { BadgeType } from '../../../badges/types'; export type Props = { + badges?: ReadonlyArray; canEdit: boolean; conversation: ConversationType; i18n: LocalizerType; @@ -22,11 +25,18 @@ export type Props = { isMe: boolean; memberships: Array; startEditing: (isGroupTitle: boolean) => void; + theme: ThemeType; }; +enum ConversationDetailsHeaderActiveModal { + ShowingAvatar, + ShowingBadges, +} + const bem = bemGenerator('ConversationDetails-header'); export const ConversationDetailsHeader: React.ComponentType = ({ + badges, canEdit, conversation, i18n, @@ -34,9 +44,13 @@ export const ConversationDetailsHeader: React.ComponentType = ({ isMe, memberships, startEditing, + theme, }) => { - const [showingAvatar, setShowingAvatar] = useState(false); + const [activeModal, setActiveModal] = useState< + undefined | ConversationDetailsHeaderActiveModal + >(); + let preferredBadge: undefined | BadgeType; let subtitle: ReactNode; if (isGroup) { if (conversation.groupDescription) { @@ -65,17 +79,26 @@ export const ConversationDetailsHeader: React.ComponentType = ({
); + preferredBadge = badges?.[0]; } const avatar = ( setShowingAvatar(true)} + onClick={() => { + setActiveModal( + preferredBadge + ? ConversationDetailsHeaderActiveModal.ShowingBadges + : ConversationDetailsHeaderActiveModal.ShowingAvatar + ); + }} sharedGroupNames={[]} + theme={theme} /> ); @@ -87,22 +110,44 @@ export const ConversationDetailsHeader: React.ComponentType = ({
); - const avatarLightbox = - showingAvatar && !isMe ? ( - setShowingAvatar(false)} - /> - ) : null; + let modal: ReactNode; + switch (activeModal) { + case ConversationDetailsHeaderActiveModal.ShowingAvatar: + modal = ( + { + setActiveModal(undefined); + }} + /> + ); + break; + case ConversationDetailsHeaderActiveModal.ShowingBadges: + modal = ( + { + setActiveModal(undefined); + }} + title={conversation.title} + /> + ); + break; + default: + modal = null; + break; + } if (canEdit) { return (
- {avatarLightbox} + {modal} {avatar}