Display user badges
This commit is contained in:
parent
927c22ef73
commit
f647c4e053
95 changed files with 2891 additions and 424 deletions
|
@ -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<ReadonlyArray<string>> => {
|
||||
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<ReadonlyArray<string>> => {
|
||||
|
@ -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<string>;
|
||||
}): Promise<void> => {
|
||||
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,
|
||||
|
|
|
@ -292,6 +292,7 @@ function prepareUrl(
|
|||
buildExpiration: config.get<number | undefined>('buildExpiration'),
|
||||
serverUrl: config.get<string>('serverUrl'),
|
||||
storageUrl: config.get<string>('storageUrl'),
|
||||
updatesUrl: config.get<string>('updatesUrl'),
|
||||
directoryUrl: config.get<string>('directoryUrl'),
|
||||
directoryEnclaveId: config.get<string>('directoryEnclaveId'),
|
||||
directoryTrustAnchor: config.get<string>('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,
|
||||
|
|
1
fixtures/blue-heart.svg
Normal file
1
fixtures/blue-heart.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m12 4.435c-1.989-5.399-12-4.597-12 3.568 0 4.068 3.06 9.481 12 14.997 8.94-5.516 12-10.929 12-14.997 0-8.118-10-8.999-12-3.568z" fill="#09f"/></svg>
|
After Width: | Height: | Size: 240 B |
1
fixtures/orange-heart.svg
Normal file
1
fixtures/orange-heart.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m12 4.435c-1.989-5.399-12-4.597-12 3.568 0 4.068 3.06 9.481 12 14.997 8.94-5.516 12-10.929 12-14.997 0-8.118-10-8.999-12-3.568z" fill="#f90"/></svg>
|
After Width: | Height: | Size: 240 B |
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -105,4 +105,11 @@
|
|||
&__spinner-container {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
|
35
stylesheets/components/BadgeCarouselIndex.scss
Normal file
35
stylesheets/components/BadgeCarouselIndex.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
111
stylesheets/components/BadgeDialog.scss
Normal file
111
stylesheets/components/BadgeDialog.scss
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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<void> {
|
|||
|
||||
await window.Signal.Util.initializeMessageCounter();
|
||||
|
||||
let initialBadgesState: BadgesStateType = { byId: {} };
|
||||
async function loadInitialBadgesState(): Promise<void> {
|
||||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
|
||||
window.dispatchEvent(new Event('storage_ready'));
|
||||
|
||||
badgeImageFileDownloader.checkForFilesToDownload();
|
||||
|
||||
log.info('Expiration start timestamp cleanup: starting...');
|
||||
const messagesUnexpectedlyMissingExpirationStartTimestamp = await window.Signal.Data.getMessagesUnexpectedlyMissingExpirationStartTimestamp();
|
||||
log.info(
|
||||
|
|
15
ts/badges/BadgeCategory.ts
Normal file
15
ts/badges/BadgeCategory.ts
Normal file
|
@ -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
|
||||
);
|
15
ts/badges/BadgeImageTheme.ts
Normal file
15
ts/badges/BadgeImageTheme.ts
Normal file
|
@ -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
|
||||
);
|
101
ts/badges/badgeImageFileDownloader.ts
Normal file
101
ts/badges/badgeImageFileDownloader.ts
Normal file
|
@ -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<void> {
|
||||
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<string> {
|
||||
const result: Array<string> = [];
|
||||
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<string> {
|
||||
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;
|
||||
}
|
33
ts/badges/getBadgeImageFileLocalPath.ts
Normal file
33
ts/badges/getBadgeImageFileLocalPath.ts
Normal file
|
@ -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<undefined | BadgeType>,
|
||||
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;
|
||||
}
|
12
ts/badges/isBadgeImageFileUrlValid.ts
Normal file
12
ts/badges/isBadgeImageFileUrlValid.ts
Normal file
|
@ -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));
|
||||
}
|
123
ts/badges/parseBadgesFromServer.ts
Normal file
123
ts/badges/parseBadgesFromServer.ts
Normal file
|
@ -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<BadgeType> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: Array<BadgeType> = [];
|
||||
|
||||
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<Record<string, string>>,
|
||||
rawSvg: string,
|
||||
updatesUrl: string
|
||||
): Array<BadgeImageType> => {
|
||||
const result: Array<BadgeImageType> = [];
|
||||
|
||||
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();
|
30
ts/badges/types.ts
Normal file
30
ts/badges/types.ts
Normal file
|
@ -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<BadgeImageType>;
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
type OurBadgeType = SomeoneElsesBadgeType &
|
||||
Readonly<{
|
||||
expiresAt: number;
|
||||
isVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type BadgeType = SomeoneElsesBadgeType | OurBadgeType;
|
||||
|
||||
export type BadgeImageType = Partial<
|
||||
Record<BadgeImageTheme, BadgeImageFileType>
|
||||
>;
|
||||
|
||||
export type BadgeImageFileType = {
|
||||
localPath?: string;
|
||||
url: string;
|
||||
};
|
|
@ -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<ConversationType>;
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</Modal>
|
||||
|
|
|
@ -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> = {}): 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 => <Avatar key={size} {...props} size={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 => (
|
||||
<Avatar key={size} {...props} size={size} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return <Wrapper />;
|
||||
});
|
||||
|
||||
story.add('Wide image', () => {
|
||||
const props = createProps({
|
||||
avatarPath: '/fixtures/wide.jpg',
|
||||
|
|
|
@ -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<string>;
|
||||
size: AvatarSize;
|
||||
theme?: ThemeType;
|
||||
title: string;
|
||||
unblurredAvatarPath?: string;
|
||||
|
||||
|
@ -72,6 +78,7 @@ const getDefaultBlur = (
|
|||
export const Avatar: FunctionComponent<Props> = ({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
badge,
|
||||
className,
|
||||
color = 'A200',
|
||||
conversationType,
|
||||
|
@ -83,6 +90,7 @@ export const Avatar: FunctionComponent<Props> = ({
|
|||
onClick,
|
||||
sharedGroupNames,
|
||||
size,
|
||||
theme,
|
||||
title,
|
||||
unblurredAvatarPath,
|
||||
blur = getDefaultBlur({
|
||||
|
@ -203,6 +211,33 @@ export const Avatar: FunctionComponent<Props> = ({
|
|||
contents = <div className={contentsClassName}>{contentsChildren}</div>;
|
||||
}
|
||||
|
||||
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 = (
|
||||
<img
|
||||
alt={badge.name}
|
||||
className="module-Avatar__badge"
|
||||
src={badgeImagePath}
|
||||
style={{
|
||||
width: badgeSize,
|
||||
height: badgeSize,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (badge && !theme) {
|
||||
log.error('<Avatar> requires a theme if a badge is provided');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={i18n('contactAvatarAlt', [title])}
|
||||
|
@ -219,6 +254,7 @@ export const Avatar: FunctionComponent<Props> = ({
|
|||
ref={innerRef}
|
||||
>
|
||||
{contents}
|
||||
{badgeNode}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
40
ts/components/BadgeCarouselIndex.tsx
Normal file
40
ts/components/BadgeCarouselIndex.tsx
Normal file
|
@ -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 (
|
||||
<div aria-hidden className="BadgeCarouselIndex">
|
||||
{times(totalCount, index => (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames(
|
||||
'BadgeCarouselIndex__dot',
|
||||
currentIndex === index && 'BadgeCarouselIndex__dot--selected'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
24
ts/components/BadgeDescription.stories.tsx
Normal file
24
ts/components/BadgeDescription.stories.tsx
Normal file
|
@ -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', () => (
|
||||
<BadgeDescription
|
||||
template="{short_name} is here! Hello, {short_name}! {short_name}, I think you're great. This is not replaced: {not_replaced}"
|
||||
firstName="Alice"
|
||||
title="Should not be seen"
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Name with RTL overrides', () => (
|
||||
<BadgeDescription
|
||||
template="Hello, {short_name}! {short_name}, I think you're great."
|
||||
title={'Flip-\u202eflop'}
|
||||
/>
|
||||
));
|
42
ts/components/BadgeDescription.tsx
Normal file
42
ts/components/BadgeDescription.tsx
Normal file
|
@ -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<ReactChild> = [];
|
||||
|
||||
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(
|
||||
<ContactName
|
||||
key={matchIndex}
|
||||
firstName={firstName}
|
||||
title={title}
|
||||
preferFirstName
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = matchIndex + 12;
|
||||
}
|
||||
|
||||
result.push(template.slice(lastIndex));
|
||||
|
||||
return <>{result}</>;
|
||||
}
|
97
ts/components/BadgeDialog.stories.tsx
Normal file
97
ts/components/BadgeDialog.stories.tsx
Normal file
|
@ -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<typeof BadgeDialog> = {
|
||||
badges: getFakeBadges(3),
|
||||
firstName: 'Alice',
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
title: 'Alice Levine',
|
||||
};
|
||||
|
||||
story.add('No badges (closed immediately)', () => (
|
||||
<BadgeDialog {...defaultProps} badges={[]} />
|
||||
));
|
||||
|
||||
story.add('One badge', () => (
|
||||
<BadgeDialog {...defaultProps} badges={getFakeBadges(1)} />
|
||||
));
|
||||
|
||||
story.add('Badge with no image (should be impossible)', () => (
|
||||
<BadgeDialog
|
||||
{...defaultProps}
|
||||
badges={[
|
||||
{
|
||||
...getFakeBadge(),
|
||||
images: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Badge with pending image', () => (
|
||||
<BadgeDialog
|
||||
{...defaultProps}
|
||||
badges={[
|
||||
{
|
||||
...getFakeBadge(),
|
||||
images: Array(4).fill(
|
||||
zipObject(
|
||||
Object.values(BadgeImageTheme),
|
||||
repeat({ url: 'https://example.com/ignored.svg' })
|
||||
)
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Badge with only one, low-detail image', () => (
|
||||
<BadgeDialog
|
||||
{...defaultProps}
|
||||
badges={[
|
||||
{
|
||||
...getFakeBadge(),
|
||||
images: [
|
||||
zipObject(
|
||||
Object.values(BadgeImageTheme),
|
||||
repeat({
|
||||
localPath: '/fixtures/orange-heart.svg',
|
||||
url: 'https://example.com/ignored.svg',
|
||||
})
|
||||
),
|
||||
...Array(3).fill(
|
||||
zipObject(
|
||||
Object.values(BadgeImageTheme),
|
||||
repeat({ url: 'https://example.com/ignored.svg' })
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Five badges', () => (
|
||||
<BadgeDialog {...defaultProps} badges={getFakeBadges(5)} />
|
||||
));
|
||||
|
||||
story.add('Many badges', () => (
|
||||
<BadgeDialog {...defaultProps} badges={getFakeBadges(50)} />
|
||||
));
|
109
ts/components/BadgeDialog.tsx
Normal file
109
ts/components/BadgeDialog.tsx
Normal file
|
@ -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<BadgeType>;
|
||||
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 ? <BadgeDialogWithBadges {...props} /> : null;
|
||||
}
|
||||
|
||||
function BadgeDialogWithBadges({
|
||||
badges,
|
||||
firstName,
|
||||
i18n,
|
||||
onClose,
|
||||
title,
|
||||
}: PropsType): JSX.Element {
|
||||
const firstBadge = badges[0];
|
||||
strictAssert(
|
||||
firstBadge,
|
||||
'<BadgeDialogWithBadges> 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, '<BadgeDialog> tried to select a nonexistent badge');
|
||||
setCurrentBadgeId(newBadge.id);
|
||||
};
|
||||
|
||||
const navigate = (change: number): void => {
|
||||
setCurrentBadgeIndex(currentBadgeIndex + change);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hasXButton
|
||||
moduleClassName="BadgeDialog"
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
>
|
||||
<button
|
||||
aria-label={i18n('previous')}
|
||||
className="BadgeDialog__nav BadgeDialog__nav--previous"
|
||||
disabled={currentBadgeIndex === 0}
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="BadgeDialog__main">
|
||||
<BadgeImage badge={currentBadge} size={200} />
|
||||
<div className="BadgeDialog__name">{currentBadge.name}</div>
|
||||
<div className="BadgeDialog__description">
|
||||
<BadgeDescription
|
||||
firstName={firstName}
|
||||
template={currentBadge.descriptionTemplate}
|
||||
title={title}
|
||||
/>
|
||||
</div>
|
||||
<BadgeCarouselIndex
|
||||
currentIndex={currentBadgeIndex}
|
||||
totalCount={badges.length}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
aria-label={i18n('next')}
|
||||
className="BadgeDialog__nav BadgeDialog__nav--next"
|
||||
disabled={currentBadgeIndex === badges.length - 1}
|
||||
onClick={() => navigate(1)}
|
||||
type="button"
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
48
ts/components/BadgeImage.tsx
Normal file
48
ts/components/BadgeImage.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import { Spinner } from './Spinner';
|
||||
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
|
||||
import { BadgeImageTheme } from '../badges/BadgeImageTheme';
|
||||
|
||||
export function BadgeImage({
|
||||
badge,
|
||||
size,
|
||||
}: Readonly<{
|
||||
badge: BadgeType;
|
||||
size: number;
|
||||
}>): JSX.Element {
|
||||
const { name } = badge;
|
||||
|
||||
const imagePath = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
size,
|
||||
BadgeImageTheme.Transparent
|
||||
);
|
||||
|
||||
if (!imagePath) {
|
||||
return (
|
||||
<Spinner
|
||||
ariaLabel={name}
|
||||
moduleClassName="BadgeImage BadgeImage__loading"
|
||||
size={`${size}px`}
|
||||
svgSize="normal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={name}
|
||||
className="BadgeImage"
|
||||
src={imagePath}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -15,6 +15,7 @@ import enMessages from '../../_locales/en/messages.json';
|
|||
|
||||
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
||||
import { landscapeGreenUrl } from '../storybook/Fixtures';
|
||||
import { ThemeType } from '../types/Util';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -31,6 +32,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
onSendMessage: action('onSendMessage'),
|
||||
processAttachments: action('processAttachments'),
|
||||
removeAttachment: action('removeAttachment'),
|
||||
theme: ThemeType.light,
|
||||
|
||||
// AttachmentList
|
||||
draftAttachments: overrideProps.draftAttachments || [],
|
||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
|||
BodyRangeType,
|
||||
BodyRangesType,
|
||||
LocalizerType,
|
||||
ThemeType,
|
||||
} from '../types/Util';
|
||||
import type { ErrorDialogAudioRecorderType } from '../state/ducks/audioRecorder';
|
||||
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
|
||||
|
@ -117,6 +118,7 @@ export type OwnProps = Readonly<{
|
|||
setQuotedMessage(message: undefined): unknown;
|
||||
shouldSendHighQualityAttachments: boolean;
|
||||
startRecording: () => unknown;
|
||||
theme: ThemeType;
|
||||
}>;
|
||||
|
||||
export type Props = Pick<
|
||||
|
@ -162,6 +164,7 @@ export const CompositionArea = ({
|
|||
onSendMessage,
|
||||
processAttachments,
|
||||
removeAttachment,
|
||||
theme,
|
||||
|
||||
// AttachmentList
|
||||
draftAttachments,
|
||||
|
@ -542,6 +545,7 @@ export const CompositionArea = ({
|
|||
groupAdmins={groupAdmins}
|
||||
i18n={i18n}
|
||||
openConversation={openConversation}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { times, omit } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean, date, select, text } from '@storybook/addon-knobs';
|
||||
|
||||
import type { PropsType, Row } from './ConversationList';
|
||||
import type { Row } from './ConversationList';
|
||||
import { ConversationList, RowType } from './ConversationList';
|
||||
import { MessageSearchResult } from './conversationList/MessageSearchResult';
|
||||
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
||||
|
@ -17,6 +17,7 @@ import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbo
|
|||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -46,52 +47,58 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
|
|||
getDefaultConversation(),
|
||||
];
|
||||
|
||||
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
||||
dimensions: {
|
||||
width: 300,
|
||||
height: 350,
|
||||
},
|
||||
rowCount: rows.length,
|
||||
getRow: (index: number) => rows[index],
|
||||
shouldRecomputeRowHeights: false,
|
||||
i18n,
|
||||
onSelectConversation: action('onSelectConversation'),
|
||||
onClickArchiveButton: action('onClickArchiveButton'),
|
||||
onClickContactCheckbox: action('onClickContactCheckbox'),
|
||||
renderMessageSearchResult: (id: string) => (
|
||||
<MessageSearchResult
|
||||
body="Lorem ipsum wow"
|
||||
bodyRanges={[]}
|
||||
conversationId="marc-convo"
|
||||
from={defaultConversations[0]}
|
||||
const Wrapper = ({
|
||||
rows,
|
||||
scrollable,
|
||||
}: Readonly<{ rows: ReadonlyArray<Row>; scrollable?: boolean }>) => {
|
||||
const theme = useContext(StorybookThemeContext);
|
||||
|
||||
return (
|
||||
<ConversationList
|
||||
dimensions={{
|
||||
width: 300,
|
||||
height: 350,
|
||||
}}
|
||||
rowCount={rows.length}
|
||||
getRow={(index: number) => rows[index]}
|
||||
shouldRecomputeRowHeights={false}
|
||||
i18n={i18n}
|
||||
id={id}
|
||||
openConversationInternal={action('openConversationInternal')}
|
||||
sentAt={1587358800000}
|
||||
snippet="Lorem <<left>>ipsum<<right>> wow"
|
||||
to={defaultConversations[1]}
|
||||
onSelectConversation={action('onSelectConversation')}
|
||||
onClickArchiveButton={action('onClickArchiveButton')}
|
||||
onClickContactCheckbox={action('onClickContactCheckbox')}
|
||||
renderMessageSearchResult={(id: string) => (
|
||||
<MessageSearchResult
|
||||
body="Lorem ipsum wow"
|
||||
bodyRanges={[]}
|
||||
conversationId="marc-convo"
|
||||
from={defaultConversations[0]}
|
||||
i18n={i18n}
|
||||
id={id}
|
||||
openConversationInternal={action('openConversationInternal')}
|
||||
sentAt={1587358800000}
|
||||
snippet="Lorem <<left>>ipsum<<right>> wow"
|
||||
to={defaultConversations[1]}
|
||||
/>
|
||||
)}
|
||||
scrollable={scrollable}
|
||||
showChooseGroupMembers={action('showChooseGroupMembers')}
|
||||
startNewConversationFromPhoneNumber={action(
|
||||
'startNewConversationFromPhoneNumber'
|
||||
)}
|
||||
theme={theme}
|
||||
/>
|
||||
),
|
||||
showChooseGroupMembers: action('showChooseGroupMembers'),
|
||||
startNewConversationFromPhoneNumber: action(
|
||||
'startNewConversationFromPhoneNumber'
|
||||
),
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
story.add('Archive button', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
{
|
||||
type: RowType.ArchiveButton,
|
||||
archivedConversationsCount: 123,
|
||||
},
|
||||
])}
|
||||
<Wrapper
|
||||
rows={[{ type: RowType.ArchiveButton, archivedConversationsCount: 123 }]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact: note to self', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Contact,
|
||||
contact: {
|
||||
|
@ -100,35 +107,30 @@ story.add('Contact: note to self', () => (
|
|||
about: '🤠 should be ignored',
|
||||
},
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact: direct', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
{
|
||||
type: RowType.Contact,
|
||||
contact: defaultConversations[0],
|
||||
},
|
||||
])}
|
||||
<Wrapper
|
||||
rows={[{ type: RowType.Contact, contact: defaultConversations[0] }]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact: direct with short about', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Contact,
|
||||
contact: { ...defaultConversations[0], about: '🤠 yee haw' },
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact: direct with long about', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Contact,
|
||||
contact: {
|
||||
|
@ -137,24 +139,24 @@ story.add('Contact: direct with long about', () => (
|
|||
'🤠 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue.',
|
||||
},
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact: group', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Contact,
|
||||
contact: { ...defaultConversations[0], type: 'group' },
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact checkboxes', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[0],
|
||||
|
@ -173,13 +175,13 @@ story.add('Contact checkboxes', () => (
|
|||
},
|
||||
isChecked: true,
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact checkboxes: disabled', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[0],
|
||||
|
@ -204,7 +206,7 @@ story.add('Contact checkboxes: disabled', () => (
|
|||
isChecked: true,
|
||||
disabledReason: ContactCheckboxDisabledReason.AlreadyAdded,
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
|
@ -219,6 +221,7 @@ story.add('Contact checkboxes: disabled', () => (
|
|||
? overrideProps.acceptedMessageRequest
|
||||
: true
|
||||
),
|
||||
badges: [],
|
||||
isMe: boolean('isMe', overrideProps.isMe || false),
|
||||
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
|
||||
id: overrideProps.id || '',
|
||||
|
@ -246,13 +249,13 @@ story.add('Contact checkboxes: disabled', () => (
|
|||
const renderConversation = (
|
||||
overrideProps: Partial<ConversationListItemPropsType> = {}
|
||||
) => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation(overrideProps),
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -278,15 +281,13 @@ story.add('Contact checkboxes: disabled', () => (
|
|||
);
|
||||
|
||||
story.add('Conversations: Message Statuses', () => (
|
||||
<ConversationList
|
||||
{...createProps(
|
||||
MessageStatuses.map(status => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastMessage: { text: status, status, deletedForEveryone: false },
|
||||
}),
|
||||
}))
|
||||
)}
|
||||
<Wrapper
|
||||
rows={MessageStatuses.map(status => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastMessage: { text: status, status, deletedForEveryone: false },
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
));
|
||||
|
||||
|
@ -324,20 +325,18 @@ story.add('Contact checkboxes: disabled', () => (
|
|||
);
|
||||
|
||||
story.add('Conversations: unread count', () => (
|
||||
<ConversationList
|
||||
{...createProps(
|
||||
[4, 10, 34, 250].map(unreadCount => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastMessage: {
|
||||
text: 'Hey there!',
|
||||
status: 'delivered',
|
||||
deletedForEveryone: false,
|
||||
},
|
||||
unreadCount,
|
||||
}),
|
||||
}))
|
||||
)}
|
||||
<Wrapper
|
||||
rows={[4, 10, 34, 250].map(unreadCount => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastMessage: {
|
||||
text: 'Hey there!',
|
||||
status: 'delivered',
|
||||
deletedForEveryone: false,
|
||||
},
|
||||
unreadCount,
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
));
|
||||
|
||||
|
@ -396,19 +395,17 @@ Line 4, well.`,
|
|||
];
|
||||
|
||||
return (
|
||||
<ConversationList
|
||||
{...createProps(
|
||||
messages.map(messageText => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastMessage: {
|
||||
text: messageText,
|
||||
status: 'read',
|
||||
deletedForEveryone: false,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
)}
|
||||
<Wrapper
|
||||
rows={messages.map(messageText => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastMessage: {
|
||||
text: messageText,
|
||||
status: 'read',
|
||||
deletedForEveryone: false,
|
||||
},
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -422,20 +419,18 @@ Line 4, well.`,
|
|||
];
|
||||
|
||||
return (
|
||||
<ConversationList
|
||||
{...createProps(
|
||||
pairs.map(([lastUpdated, messageText]) => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastUpdated,
|
||||
lastMessage: {
|
||||
text: messageText,
|
||||
status: 'read',
|
||||
deletedForEveryone: false,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
)}
|
||||
<Wrapper
|
||||
rows={pairs.map(([lastUpdated, messageText]) => ({
|
||||
type: RowType.Conversation,
|
||||
conversation: createConversation({
|
||||
lastUpdated,
|
||||
lastMessage: {
|
||||
text: messageText,
|
||||
status: 'read',
|
||||
deletedForEveryone: false,
|
||||
},
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -446,7 +441,7 @@ Line 4, well.`,
|
|||
conversation: omit(createConversation(), 'lastUpdated'),
|
||||
};
|
||||
|
||||
return <ConversationList {...createProps([row])} />;
|
||||
return <Wrapper rows={[row]} />;
|
||||
});
|
||||
|
||||
story.add('Conversation: Missing Message', () => {
|
||||
|
@ -455,7 +450,7 @@ Line 4, well.`,
|
|||
conversation: omit(createConversation(), 'lastMessage'),
|
||||
};
|
||||
|
||||
return <ConversationList {...createProps([row])} />;
|
||||
return <Wrapper rows={[row]} />;
|
||||
});
|
||||
|
||||
story.add('Conversation: Missing Text', () =>
|
||||
|
@ -488,8 +483,8 @@ Line 4, well.`,
|
|||
}
|
||||
|
||||
story.add('Headers', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Header,
|
||||
i18nKey: 'conversationsHeader',
|
||||
|
@ -498,36 +493,36 @@ story.add('Headers', () => (
|
|||
type: RowType.Header,
|
||||
i18nKey: 'messagesHeader',
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Start new conversation', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.StartNewConversation,
|
||||
phoneNumber: '+12345559876',
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Search results loading skeleton', () => (
|
||||
<ConversationList
|
||||
<Wrapper
|
||||
scrollable={false}
|
||||
{...createProps([
|
||||
rows={[
|
||||
{ type: RowType.SearchResultsLoadingFakeHeader },
|
||||
...times(99, () => ({
|
||||
type: RowType.SearchResultsLoadingFakeRow as const,
|
||||
})),
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Kitchen sink', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.StartNewConversation,
|
||||
phoneNumber: '+12345559876',
|
||||
|
@ -552,6 +547,6 @@ story.add('Kitchen sink', () => (
|
|||
type: RowType.ArchiveButton,
|
||||
archivedConversationsCount: 123,
|
||||
},
|
||||
])}
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -8,11 +8,13 @@ import { List } from 'react-virtualized';
|
|||
import classNames from 'classnames';
|
||||
import { get, pick } from 'lodash';
|
||||
|
||||
import { getOwn } from '../util/getOwn';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { assert } from '../util/assert';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { ScrollBehavior } from '../types/Util';
|
||||
import { getConversationListWidthBreakpoint } from './_util';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
|
||||
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
||||
import { ConversationListItem } from './conversationList/ConversationListItem';
|
||||
|
@ -105,6 +107,7 @@ export type Row =
|
|||
| StartNewConversationRowType;
|
||||
|
||||
export type PropsType = {
|
||||
badgesById?: Record<string, BadgeType>;
|
||||
dimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
|
@ -120,6 +123,7 @@ export type PropsType = {
|
|||
scrollable?: boolean;
|
||||
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
|
||||
onClickArchiveButton: () => void;
|
||||
onClickContactCheckbox: (
|
||||
|
@ -136,6 +140,7 @@ const NORMAL_ROW_HEIGHT = 76;
|
|||
const HEADER_ROW_HEIGHT = 40;
|
||||
|
||||
export const ConversationList: React.FC<PropsType> = ({
|
||||
badgesById,
|
||||
dimensions,
|
||||
getRow,
|
||||
i18n,
|
||||
|
@ -150,6 +155,7 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
shouldRecomputeRowHeights,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
theme,
|
||||
}) => {
|
||||
const listRef = useRef<null | List>(null);
|
||||
|
||||
|
@ -235,6 +241,7 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
const itemProps = pick(row.conversation, [
|
||||
'acceptedMessageRequest',
|
||||
'avatarPath',
|
||||
'badges',
|
||||
'color',
|
||||
'draftPreview',
|
||||
'id',
|
||||
|
@ -255,7 +262,12 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
'unblurredAvatarPath',
|
||||
'unreadCount',
|
||||
]);
|
||||
const { title, unreadCount, lastMessage } = itemProps;
|
||||
const { badges, title, unreadCount, lastMessage } = itemProps;
|
||||
|
||||
let badge: undefined | BadgeType;
|
||||
if (badgesById && badges[0]) {
|
||||
badge = getOwn(badgesById, badges[0].id);
|
||||
}
|
||||
|
||||
result = (
|
||||
<div
|
||||
|
@ -270,8 +282,10 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
<ConversationListItem
|
||||
{...itemProps}
|
||||
key={key}
|
||||
badge={badge}
|
||||
onClick={onSelectConversation}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -326,6 +340,7 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
);
|
||||
},
|
||||
[
|
||||
badgesById,
|
||||
getRow,
|
||||
i18n,
|
||||
onClickArchiveButton,
|
||||
|
@ -334,6 +349,7 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
renderMessageSearchResult,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
theme,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import { ForwardMessageModal } from './ForwardMessageModal';
|
|||
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||
|
||||
const createAttachment = (
|
||||
props: Partial<AttachmentType> = {}
|
||||
|
@ -39,7 +40,7 @@ const candidateConversations = Array.from(Array(100), () =>
|
|||
getDefaultConversation()
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
attachments: overrideProps.attachments,
|
||||
candidateConversations,
|
||||
doForwardMessage: action('doForwardMessage'),
|
||||
|
@ -55,24 +56,25 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
recentEmojis: [],
|
||||
removeLinkPreview: action('removeLinkPreview'),
|
||||
skinTone: 0,
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
});
|
||||
|
||||
story.add('Modal', () => {
|
||||
return <ForwardMessageModal {...createProps()} />;
|
||||
return <ForwardMessageModal {...useProps()} />;
|
||||
});
|
||||
|
||||
story.add('with text', () => {
|
||||
return <ForwardMessageModal {...createProps({ messageBody: 'sup' })} />;
|
||||
return <ForwardMessageModal {...useProps({ messageBody: 'sup' })} />;
|
||||
});
|
||||
|
||||
story.add('a sticker', () => {
|
||||
return <ForwardMessageModal {...createProps({ isSticker: true })} />;
|
||||
return <ForwardMessageModal {...useProps({ isSticker: true })} />;
|
||||
});
|
||||
|
||||
story.add('link preview', () => {
|
||||
return (
|
||||
<ForwardMessageModal
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
linkPreview: {
|
||||
description: LONG_DESCRIPTION,
|
||||
date: Date.now(),
|
||||
|
@ -94,7 +96,7 @@ story.add('link preview', () => {
|
|||
story.add('media attachments', () => {
|
||||
return (
|
||||
<ForwardMessageModal
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
attachments: [
|
||||
createAttachment({
|
||||
contentType: IMAGE_JPEG,
|
||||
|
@ -122,7 +124,7 @@ story.add('media attachments', () => {
|
|||
|
||||
story.add('announcement only groups non-admin', () => (
|
||||
<ForwardMessageModal
|
||||
{...createProps()}
|
||||
{...useProps()}
|
||||
candidateConversations={[
|
||||
getDefaultConversation({
|
||||
announcementsOnly: true,
|
||||
|
|
|
@ -29,7 +29,7 @@ import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
|||
import { EmojiButton } from './emoji/EmojiButton';
|
||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
||||
import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util';
|
||||
import { ModalHost } from './ModalHost';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
|
@ -57,6 +57,7 @@ export type DataPropsType = {
|
|||
caretLocation?: number
|
||||
) => unknown;
|
||||
onTextTooLong: () => void;
|
||||
theme: ThemeType;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
|
||||
type ActionPropsType = Pick<
|
||||
|
@ -86,6 +87,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
recentEmojis,
|
||||
removeLinkPreview,
|
||||
skinTone,
|
||||
theme,
|
||||
}) => {
|
||||
const inputRef = useRef<null | HTMLInputElement>(null);
|
||||
const inputApiRef = React.useRef<InputApi | undefined>();
|
||||
|
@ -412,6 +414,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
startNewConversationFromPhoneNumber={
|
||||
shouldNeverBeCalled
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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> = {}): PropsType => ({
|
||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
badgesById: {},
|
||||
cantAddContactToGroup: action('cantAddContactToGroup'),
|
||||
canResizeLeftPane: true,
|
||||
clearGroupCreationError: action('clearGroupCreationError'),
|
||||
|
@ -146,6 +148,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): 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> = {}): PropsType => ({
|
|||
|
||||
story.add('Inbox: no conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations: [],
|
||||
|
@ -174,7 +177,7 @@ story.add('Inbox: no conversations', () => (
|
|||
|
||||
story.add('Inbox: only pinned conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
|
@ -189,7 +192,7 @@ story.add('Inbox: only pinned conversations', () => (
|
|||
|
||||
story.add('Inbox: only non-pinned conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations: [],
|
||||
|
@ -204,7 +207,7 @@ story.add('Inbox: only non-pinned conversations', () => (
|
|||
|
||||
story.add('Inbox: only archived conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations: [],
|
||||
|
@ -219,7 +222,7 @@ story.add('Inbox: only archived conversations', () => (
|
|||
|
||||
story.add('Inbox: pinned and archived conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
|
@ -234,7 +237,7 @@ story.add('Inbox: pinned and archived conversations', () => (
|
|||
|
||||
story.add('Inbox: non-pinned and archived conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations: [],
|
||||
|
@ -249,7 +252,7 @@ story.add('Inbox: non-pinned and archived conversations', () => (
|
|||
|
||||
story.add('Inbox: pinned and non-pinned conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
|
@ -263,14 +266,14 @@ story.add('Inbox: pinned and non-pinned conversations', () => (
|
|||
));
|
||||
|
||||
story.add('Inbox: pinned, non-pinned, and archived conversations', () => (
|
||||
<LeftPane {...createProps()} />
|
||||
<LeftPane {...useProps()} />
|
||||
));
|
||||
|
||||
// Search stories
|
||||
|
||||
story.add('Search: no results when searching everywhere', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: emptySearchResultsGroup,
|
||||
|
@ -285,7 +288,7 @@ story.add('Search: no results when searching everywhere', () => (
|
|||
|
||||
story.add('Search: no results when searching everywhere (SMS)', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: emptySearchResultsGroup,
|
||||
|
@ -300,7 +303,7 @@ story.add('Search: no results when searching everywhere (SMS)', () => (
|
|||
|
||||
story.add('Search: no results when searching in a conversation', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: emptySearchResultsGroup,
|
||||
|
@ -316,7 +319,7 @@ story.add('Search: no results when searching in a conversation', () => (
|
|||
|
||||
story.add('Search: all results loading', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: { isLoading: true },
|
||||
|
@ -331,7 +334,7 @@ story.add('Search: all results loading', () => (
|
|||
|
||||
story.add('Search: some results loading', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: {
|
||||
|
@ -349,7 +352,7 @@ story.add('Search: some results loading', () => (
|
|||
|
||||
story.add('Search: has conversations and contacts, but not messages', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: {
|
||||
|
@ -367,7 +370,7 @@ story.add('Search: has conversations and contacts, but not messages', () => (
|
|||
|
||||
story.add('Search: all results', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Search,
|
||||
conversationResults: {
|
||||
|
@ -393,7 +396,7 @@ story.add('Search: all results', () => (
|
|||
|
||||
story.add('Archive: no archived conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: [],
|
||||
|
@ -406,7 +409,7 @@ story.add('Archive: no archived conversations', () => (
|
|||
|
||||
story.add('Archive: archived conversations', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: defaultConversations,
|
||||
|
@ -419,7 +422,7 @@ story.add('Archive: archived conversations', () => (
|
|||
|
||||
story.add('Archive: searching a conversation', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: defaultConversations,
|
||||
|
@ -438,7 +441,7 @@ story.add('Archive: searching a conversation', () => (
|
|||
|
||||
story.add('Compose: no contacts or groups', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: [],
|
||||
|
@ -452,7 +455,7 @@ story.add('Compose: no contacts or groups', () => (
|
|||
|
||||
story.add('Compose: some contacts, no groups, no search term', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: defaultConversations,
|
||||
|
@ -466,7 +469,7 @@ story.add('Compose: some contacts, no groups, no search term', () => (
|
|||
|
||||
story.add('Compose: some contacts, no groups, with a search term', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: defaultConversations,
|
||||
|
@ -480,7 +483,7 @@ story.add('Compose: some contacts, no groups, with a search term', () => (
|
|||
|
||||
story.add('Compose: some groups, no contacts, no search term', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: [],
|
||||
|
@ -494,7 +497,7 @@ story.add('Compose: some groups, no contacts, no search term', () => (
|
|||
|
||||
story.add('Compose: some groups, no contacts, with search term', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: [],
|
||||
|
@ -508,7 +511,7 @@ story.add('Compose: some groups, no contacts, with search term', () => (
|
|||
|
||||
story.add('Compose: some contacts, some groups, no search term', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: defaultConversations,
|
||||
|
@ -522,7 +525,7 @@ story.add('Compose: some contacts, some groups, no search term', () => (
|
|||
|
||||
story.add('Compose: some contacts, some groups, with a search term', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Compose,
|
||||
composeContacts: defaultConversations,
|
||||
|
@ -538,7 +541,7 @@ story.add('Compose: some contacts, some groups, with a search term', () => (
|
|||
|
||||
story.add('Captcha dialog: required', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
|
@ -554,7 +557,7 @@ story.add('Captcha dialog: required', () => (
|
|||
|
||||
story.add('Captcha dialog: pending', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
|
@ -572,7 +575,7 @@ story.add('Captcha dialog: pending', () => (
|
|||
|
||||
story.add('Group Metadata: No Timer', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.SetGroupMetadata,
|
||||
groupAvatar: undefined,
|
||||
|
@ -590,7 +593,7 @@ story.add('Group Metadata: No Timer', () => (
|
|||
|
||||
story.add('Group Metadata: Regular Timer', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.SetGroupMetadata,
|
||||
groupAvatar: undefined,
|
||||
|
@ -608,7 +611,7 @@ story.add('Group Metadata: Regular Timer', () => (
|
|||
|
||||
story.add('Group Metadata: Custom Timer', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.SetGroupMetadata,
|
||||
groupAvatar: undefined,
|
||||
|
|
|
@ -23,8 +23,9 @@ import type { LeftPaneSetGroupMetadataPropsType } from './leftPane/LeftPaneSetGr
|
|||
import { LeftPaneSetGroupMetadataHelper } from './leftPane/LeftPaneSetGroupMetadataHelper';
|
||||
|
||||
import * as OS from '../OS';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { ScrollBehavior } from '../types/Util';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
@ -83,6 +84,7 @@ export type PropsType = {
|
|||
mode: LeftPaneMode.SetGroupMetadata;
|
||||
} & LeftPaneSetGroupMetadataPropsType);
|
||||
i18n: LocalizerType;
|
||||
badgesById: Record<string, BadgeType>;
|
||||
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<PropsType> = ({
|
||||
badgesById,
|
||||
cantAddContactToGroup,
|
||||
canResizeLeftPane,
|
||||
challengeStatus,
|
||||
|
@ -182,6 +186,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
startSearch,
|
||||
startNewConversationFromPhoneNumber,
|
||||
startSettingGroupMetadata,
|
||||
theme,
|
||||
toggleComposeEditingAvatar,
|
||||
toggleConversationInChooseMembers,
|
||||
updateSearchTerm,
|
||||
|
@ -565,6 +570,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
badgesById={badgesById}
|
||||
dimensions={{
|
||||
width,
|
||||
height: contentRect.bounds?.height || 0,
|
||||
|
@ -602,6 +608,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
startNewConversationFromPhoneNumber={
|
||||
startNewConversationFromPhoneNumber
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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> = {}): 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 <ContactModal {...props} />;
|
||||
});
|
||||
|
||||
story.add('With badges', () => {
|
||||
const props = createProps({
|
||||
badges: getFakeBadges(2),
|
||||
});
|
||||
|
||||
return <ContactModal {...props} />;
|
||||
});
|
||||
|
|
|
@ -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<BadgeType>;
|
||||
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 (
|
||||
<AvatarLightbox
|
||||
avatarColor={contact.color}
|
||||
avatarPath={contact.avatarPath}
|
||||
conversationTitle={contact.title}
|
||||
i18n={i18n}
|
||||
onClose={() => setShowingAvatar(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
switch (view) {
|
||||
case ContactModalView.Default: {
|
||||
const preferredBadge: undefined | BadgeType = badges[0];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
moduleClassName="ContactModal__modal"
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={hideContactModal}
|
||||
>
|
||||
<div className="ContactModal">
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
color={contact.color}
|
||||
conversationType="direct"
|
||||
return (
|
||||
<Modal
|
||||
moduleClassName="ContactModal__modal"
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
isMe={contact.isMe}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
size={96}
|
||||
title={contact.title}
|
||||
unblurredAvatarPath={contact.unblurredAvatarPath}
|
||||
onClick={() => setShowingAvatar(true)}
|
||||
/>
|
||||
<div className="ContactModal__name">{contact.title}</div>
|
||||
<div className="module-about__container">
|
||||
<About text={contact.about} />
|
||||
</div>
|
||||
{contact.phoneNumber && (
|
||||
<div className="ContactModal__info">{contact.phoneNumber}</div>
|
||||
)}
|
||||
{!contact.isMe && (
|
||||
<div className="ContactModal__info">
|
||||
<SharedGroupNames
|
||||
onClose={hideContactModal}
|
||||
>
|
||||
<div className="ContactModal">
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
badge={preferredBadge}
|
||||
color={contact.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
sharedGroupNames={contact.sharedGroupNames || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="ContactModal__button-container">
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__send-message"
|
||||
onClick={() => {
|
||||
hideContactModal();
|
||||
openConversationInternal({ conversationId: contact.id });
|
||||
}}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__send-message__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--message')}</span>
|
||||
</button>
|
||||
{!contact.isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__safety-number"
|
||||
isMe={contact.isMe}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
size={96}
|
||||
title={contact.title}
|
||||
unblurredAvatarPath={contact.unblurredAvatarPath}
|
||||
onClick={() => {
|
||||
hideContactModal();
|
||||
toggleSafetyNumberModal(contact.id);
|
||||
setView(
|
||||
preferredBadge
|
||||
? ContactModalView.ShowingBadges
|
||||
: ContactModalView.ShowingAvatar
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__safety-number__bubble-icon" />
|
||||
/>
|
||||
<div className="ContactModal__name">{contact.title}</div>
|
||||
<div className="module-about__container">
|
||||
<About text={contact.about} />
|
||||
</div>
|
||||
{contact.phoneNumber && (
|
||||
<div className="ContactModal__info">{contact.phoneNumber}</div>
|
||||
)}
|
||||
{!contact.isMe && (
|
||||
<div className="ContactModal__info">
|
||||
<SharedGroupNames
|
||||
i18n={i18n}
|
||||
sharedGroupNames={contact.sharedGroupNames || []}
|
||||
/>
|
||||
</div>
|
||||
<span>{i18n('showSafetyNumber')}</span>
|
||||
</button>
|
||||
)}
|
||||
{!contact.isMe && areWeAdmin && isMember && conversationId && (
|
||||
<>
|
||||
)}
|
||||
<div className="ContactModal__button-container">
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__make-admin"
|
||||
onClick={() => setConfirmToggleAdmin(true)}
|
||||
className="ContactModal__button ContactModal__send-message"
|
||||
onClick={() => {
|
||||
hideContactModal();
|
||||
openConversationInternal({ conversationId: contact.id });
|
||||
}}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__make-admin__bubble-icon" />
|
||||
<div className="ContactModal__send-message__bubble-icon" />
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<span>{i18n('ContactModal--rm-admin')}</span>
|
||||
) : (
|
||||
<span>{i18n('ContactModal--make-admin')}</span>
|
||||
)}
|
||||
<span>{i18n('ContactModal--message')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__remove-from-group"
|
||||
onClick={() =>
|
||||
removeMemberFromGroup(conversationId, contact.id)
|
||||
}
|
||||
{!contact.isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__safety-number"
|
||||
onClick={() => {
|
||||
hideContactModal();
|
||||
toggleSafetyNumberModal(contact.id);
|
||||
}}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__safety-number__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('showSafetyNumber')}</span>
|
||||
</button>
|
||||
)}
|
||||
{!contact.isMe && areWeAdmin && isMember && conversationId && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__make-admin"
|
||||
onClick={() => setConfirmToggleAdmin(true)}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__make-admin__bubble-icon" />
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<span>{i18n('ContactModal--rm-admin')}</span>
|
||||
) : (
|
||||
<span>{i18n('ContactModal--make-admin')}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__remove-from-group"
|
||||
onClick={() =>
|
||||
removeMemberFromGroup(conversationId, contact.id)
|
||||
}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__remove-from-group__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--remove-from-group')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{confirmToggleAdmin && conversationId && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: () => toggleAdmin(conversationId, contact.id),
|
||||
text: isAdmin
|
||||
? i18n('ContactModal--rm-admin')
|
||||
: i18n('ContactModal--make-admin'),
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setConfirmToggleAdmin(false)}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__remove-from-group__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--remove-from-group')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{confirmToggleAdmin && conversationId && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: () => 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])}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
{isAdmin
|
||||
? i18n('ContactModal--rm-admin-info', [contact.title])
|
||||
: i18n('ContactModal--make-admin-info', [contact.title])}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
case ContactModalView.ShowingAvatar:
|
||||
return (
|
||||
<AvatarLightbox
|
||||
avatarColor={contact.color}
|
||||
avatarPath={contact.avatarPath}
|
||||
conversationTitle={contact.title}
|
||||
i18n={i18n}
|
||||
onClose={() => setView(ContactModalView.Default)}
|
||||
/>
|
||||
);
|
||||
case ContactModalView.ShowingBadges:
|
||||
return (
|
||||
<BadgeDialog
|
||||
badges={badges}
|
||||
firstName={contact.firstName}
|
||||
i18n={i18n}
|
||||
onClose={() => setView(ContactModalView.Default)}
|
||||
title={contact.title}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(view);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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<typeof ConversationHeader>;
|
||||
props: Omit<ComponentProps<typeof ConversationHeader>, 'theme'>;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
@ -317,15 +318,18 @@ const stories: Array<ConversationHeaderStory> = [
|
|||
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 (
|
||||
<div key={i}>
|
||||
{subtitle ? <h3>{subtitle}</h3> : null}
|
||||
<ConversationHeader {...props} />
|
||||
<ConversationHeader {...props} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
docs: description,
|
||||
}
|
||||
|
|
|
@ -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<PropsType, StateType> {
|
|||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
badge,
|
||||
color,
|
||||
i18n,
|
||||
type,
|
||||
|
@ -198,6 +202,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
phoneNumber,
|
||||
profileName,
|
||||
sharedGroupNames,
|
||||
theme,
|
||||
title,
|
||||
unblurredAvatarPath,
|
||||
} = this.props;
|
||||
|
@ -207,6 +212,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
color={color}
|
||||
conversationType={type}
|
||||
i18n={i18n}
|
||||
|
@ -218,6 +224,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
theme={theme}
|
||||
unblurredAvatarPath={unblurredAvatarPath}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
@ -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<React.ComponentProps<typeof ConversationHero>, 'theme'>
|
||||
) => {
|
||||
const theme = React.useContext(StorybookThemeContext);
|
||||
return <ConversationHero {...props} theme={theme} />;
|
||||
};
|
||||
|
||||
storiesOf('Components/Conversation/ConversationHero', module)
|
||||
.add('Direct (Five Other Groups)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -54,7 +62,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (Four Other Groups)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -81,7 +89,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (Three Other Groups)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -103,7 +111,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (Two Other Groups)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -125,7 +133,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (One Other Group)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -147,7 +155,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (No Groups, Name)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -169,7 +177,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (No Groups, Just Profile)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -191,7 +199,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (No Groups, Just Phone Number)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
|
@ -213,7 +221,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (No Groups, No Data)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={text('title', 'Unknown contact')}
|
||||
|
@ -234,7 +242,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Direct (No Groups, No Data, Not Accepted)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={text('title', 'Unknown contact')}
|
||||
|
@ -255,7 +263,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Group (many members)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
|
@ -274,7 +282,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Group (one member)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
|
@ -293,7 +301,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Group (zero members)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
|
@ -313,7 +321,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Group (long group description)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
|
@ -333,7 +341,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Group (No name)', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
|
@ -352,7 +360,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
|||
.add('Note to Self', () => {
|
||||
return (
|
||||
<div style={{ width: '480px' }}>
|
||||
<ConversationHero
|
||||
<Wrapper
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe
|
||||
|
|
|
@ -8,7 +8,7 @@ import { ContactName } from './ContactName';
|
|||
import { About } from './About';
|
||||
import { GroupDescription } from './GroupDescription';
|
||||
import { SharedGroupNames } from '../SharedGroupNames';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import { ConfirmationDialog } from '../ConfirmationDialog';
|
||||
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
||||
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
|
||||
|
@ -28,6 +28,7 @@ export type Props = {
|
|||
unblurAvatar: () => void;
|
||||
unblurredAvatarPath?: string;
|
||||
updateSharedGroups: () => unknown;
|
||||
theme: ThemeType;
|
||||
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
|
||||
|
||||
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 = ({
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
blur={avatarBlur}
|
||||
className="module-conversation-hero__avatar"
|
||||
color={color}
|
||||
|
@ -192,6 +196,7 @@ export const ConversationHero = ({
|
|||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={112}
|
||||
theme={theme}
|
||||
title={title}
|
||||
/>
|
||||
<h1 className="module-conversation-hero__profile-name">
|
||||
|
|
|
@ -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 = () => (
|
||||
<ConversationHero
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={getTitle()}
|
||||
avatarPath={getAvatarPath()}
|
||||
name={getName()}
|
||||
profileName={getProfileName()}
|
||||
phoneNumber={getPhoneNumber()}
|
||||
conversationType="direct"
|
||||
onHeightChange={action('onHeightChange in ConversationHero')}
|
||||
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
|
||||
unblurAvatar={action('unblurAvatar')}
|
||||
updateSharedGroups={noop}
|
||||
/>
|
||||
);
|
||||
const renderHeroRow = () => {
|
||||
const Wrapper = () => {
|
||||
const theme = React.useContext(StorybookThemeContext);
|
||||
return (
|
||||
<ConversationHero
|
||||
about={getAbout()}
|
||||
acceptedMessageRequest
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={getTitle()}
|
||||
avatarPath={getAvatarPath()}
|
||||
name={getName()}
|
||||
profileName={getProfileName()}
|
||||
phoneNumber={getPhoneNumber()}
|
||||
conversationType="direct"
|
||||
onHeightChange={action('onHeightChange in ConversationHero')}
|
||||
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
|
||||
theme={theme}
|
||||
unblurAvatar={action('unblurAvatar')}
|
||||
updateSharedGroups={noop}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return <Wrapper />;
|
||||
};
|
||||
const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
|
||||
const renderTypingBubble = () => (
|
||||
<TypingBubble
|
||||
|
|
|
@ -14,6 +14,7 @@ import enMessages from '../../../../_locales/en/messages.json';
|
|||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
import { AddGroupMembersModal } from './AddGroupMembersModal';
|
||||
import { RequestState } from './util';
|
||||
import { ThemeType } from '../../../types/Util';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -37,6 +38,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
action('onMakeRequest')(conversationIds);
|
||||
},
|
||||
requestState: RequestState.Inactive,
|
||||
theme: ThemeType.light,
|
||||
...overrideProps,
|
||||
});
|
||||
|
||||
|
|
|
@ -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<string>) => Promise<void>;
|
||||
onClose: () => void;
|
||||
requestState: RequestState;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
enum Stage {
|
||||
|
@ -151,6 +152,7 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
onClose,
|
||||
makeRequest,
|
||||
requestState,
|
||||
theme,
|
||||
}) => {
|
||||
const maxGroupSize = getMaximumNumberOfContacts();
|
||||
const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts();
|
||||
|
@ -284,6 +286,7 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
selectedContacts={selectedContacts}
|
||||
setCantAddContactForModal={setCantAddContactForModal}
|
||||
setSearchTerm={setSearchTerm}
|
||||
theme={theme}
|
||||
toggleSelectedContact={toggleSelectedContact}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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<undefined | ConversationType>
|
||||
) => void;
|
||||
setSearchTerm: (_: string) => void;
|
||||
theme: ThemeType;
|
||||
toggleSelectedContact: (conversationId: string) => void;
|
||||
};
|
||||
|
||||
|
@ -55,6 +56,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
selectedContacts,
|
||||
setCantAddContactForModal,
|
||||
setSearchTerm,
|
||||
theme,
|
||||
toggleSelectedContact,
|
||||
}) => {
|
||||
const [focusRef] = useRestoreFocus();
|
||||
|
@ -227,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
shouldRecomputeRowHeights={false}
|
||||
showChooseGroupMembers={shouldNeverBeCalled}
|
||||
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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<string>) => Promise<void>;
|
||||
badges?: ReadonlyArray<BadgeType>;
|
||||
canEditGroupInfo: boolean;
|
||||
candidateContactsToAdd: Array<ConversationType>;
|
||||
conversation?: ConversationType;
|
||||
|
@ -62,6 +64,7 @@ export type StateProps = {
|
|||
isGroup: boolean;
|
||||
loadRecentMediaItems: (limit: number) => void;
|
||||
memberships: Array<GroupV2Membership>;
|
||||
preferredBadgeByConversation: Record<string, BadgeType>;
|
||||
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
||||
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
||||
setDisappearingMessages: (seconds: number) => void;
|
||||
|
@ -85,6 +88,7 @@ export type StateProps = {
|
|||
onBlock: () => void;
|
||||
onLeave: () => void;
|
||||
onUnblock: () => void;
|
||||
theme: ThemeType;
|
||||
userAvatarData: Array<AvatarDataType>;
|
||||
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
|
||||
onOutgoingAudioCallInConversation: () => unknown;
|
||||
|
@ -104,6 +108,7 @@ export type Props = StateProps & ActionProps;
|
|||
|
||||
export const ConversationDetails: React.ComponentType<Props> = ({
|
||||
addMembers,
|
||||
badges,
|
||||
canEditGroupInfo,
|
||||
candidateContactsToAdd,
|
||||
conversation,
|
||||
|
@ -121,6 +126,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
onUnblock,
|
||||
pendingApprovalMemberships,
|
||||
pendingMemberships,
|
||||
preferredBadgeByConversation,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
searchInConversation,
|
||||
|
@ -134,6 +140,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
showGroupV2Permissions,
|
||||
showLightboxForMedia,
|
||||
showPendingInvites,
|
||||
theme,
|
||||
toggleSafetyNumberModal,
|
||||
updateGroupAttributes,
|
||||
userAvatarData,
|
||||
|
@ -256,6 +263,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
setEditGroupAttributesRequestState(RequestState.Inactive);
|
||||
}}
|
||||
requestState={addGroupMembersRequestState}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
@ -311,6 +319,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
)}
|
||||
|
||||
<ConversationDetailsHeader
|
||||
badges={badges}
|
||||
canEdit={canEditGroupInfo}
|
||||
conversation={conversation}
|
||||
i18n={i18n}
|
||||
|
@ -324,6 +333,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
: ModalState.EditingGroupDescription
|
||||
);
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
<div className="ConversationDetails__header-buttons">
|
||||
|
@ -456,10 +466,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
conversationId={conversation.id}
|
||||
i18n={i18n}
|
||||
memberships={memberships}
|
||||
preferredBadgeByConversation={preferredBadgeByConversation}
|
||||
showContactModal={showContactModal}
|
||||
startAddingNewMembers={() => {
|
||||
setModalState(ModalState.AddingGroupMembers);
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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> = {}): 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 <ConversationDetailsHeader {...props} />;
|
||||
});
|
||||
|
||||
story.add('Editable', () => {
|
||||
const props = createProps({ canEdit: true });
|
||||
|
||||
return <ConversationDetailsHeader {...props} />;
|
||||
});
|
||||
|
||||
story.add('Basic no-description', () => {
|
||||
const props = createProps();
|
||||
const Wrapper = (overrideProps: Partial<Props>) => {
|
||||
const theme = React.useContext(StorybookThemeContext);
|
||||
|
||||
return (
|
||||
<ConversationDetailsHeader
|
||||
{...props}
|
||||
conversation={getDefaultConversation({
|
||||
title: 'My Group',
|
||||
type: 'group',
|
||||
})}
|
||||
conversation={createConversation()}
|
||||
i18n={i18n}
|
||||
canEdit={false}
|
||||
startEditing={action('startEditing')}
|
||||
memberships={new Array(number('conversation members length', 0))}
|
||||
isGroup
|
||||
isMe={false}
|
||||
theme={theme}
|
||||
{...overrideProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
story.add('Editable no-description', () => {
|
||||
const props = createProps({ canEdit: true });
|
||||
story.add('Basic', () => <Wrapper />);
|
||||
|
||||
return (
|
||||
<ConversationDetailsHeader
|
||||
{...props}
|
||||
conversation={getDefaultConversation({
|
||||
title: 'My Group',
|
||||
type: 'group',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
story.add('Editable', () => <Wrapper canEdit />);
|
||||
|
||||
story.add('1:1', () => (
|
||||
<ConversationDetailsHeader {...createProps()} isGroup={false} />
|
||||
story.add('Basic no-description', () => (
|
||||
<Wrapper
|
||||
conversation={getDefaultConversation({
|
||||
title: 'My Group',
|
||||
type: 'group',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Note to self', () => (
|
||||
<ConversationDetailsHeader {...createProps()} isMe />
|
||||
story.add('Editable no-description', () => (
|
||||
<Wrapper
|
||||
conversation={getDefaultConversation({
|
||||
title: 'My Group',
|
||||
type: 'group',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('1:1', () => <Wrapper isGroup={false} badges={getFakeBadges(3)} />);
|
||||
|
||||
story.add('Note to self', () => <Wrapper isMe />);
|
||||
|
|
|
@ -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<BadgeType>;
|
||||
canEdit: boolean;
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
|
@ -22,11 +25,18 @@ export type Props = {
|
|||
isMe: boolean;
|
||||
memberships: Array<GroupV2Membership>;
|
||||
startEditing: (isGroupTitle: boolean) => void;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
enum ConversationDetailsHeaderActiveModal {
|
||||
ShowingAvatar,
|
||||
ShowingBadges,
|
||||
}
|
||||
|
||||
const bem = bemGenerator('ConversationDetails-header');
|
||||
|
||||
export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
||||
badges,
|
||||
canEdit,
|
||||
conversation,
|
||||
i18n,
|
||||
|
@ -34,9 +44,13 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
|||
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<Props> = ({
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
preferredBadge = badges?.[0];
|
||||
}
|
||||
|
||||
const avatar = (
|
||||
<Avatar
|
||||
badge={preferredBadge}
|
||||
conversationType={conversation.type}
|
||||
i18n={i18n}
|
||||
size={80}
|
||||
{...conversation}
|
||||
noteToSelf={isMe}
|
||||
onClick={() => setShowingAvatar(true)}
|
||||
onClick={() => {
|
||||
setActiveModal(
|
||||
preferredBadge
|
||||
? ConversationDetailsHeaderActiveModal.ShowingBadges
|
||||
: ConversationDetailsHeaderActiveModal.ShowingAvatar
|
||||
);
|
||||
}}
|
||||
sharedGroupNames={[]}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -87,22 +110,44 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
const avatarLightbox =
|
||||
showingAvatar && !isMe ? (
|
||||
<AvatarLightbox
|
||||
avatarColor={conversation.color}
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationTitle={conversation.title}
|
||||
i18n={i18n}
|
||||
isGroup={isGroup}
|
||||
onClose={() => setShowingAvatar(false)}
|
||||
/>
|
||||
) : null;
|
||||
let modal: ReactNode;
|
||||
switch (activeModal) {
|
||||
case ConversationDetailsHeaderActiveModal.ShowingAvatar:
|
||||
modal = (
|
||||
<AvatarLightbox
|
||||
avatarColor={conversation.color}
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationTitle={conversation.title}
|
||||
i18n={i18n}
|
||||
isGroup={isGroup}
|
||||
onClose={() => {
|
||||
setActiveModal(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case ConversationDetailsHeaderActiveModal.ShowingBadges:
|
||||
modal = (
|
||||
<BadgeDialog
|
||||
badges={badges || []}
|
||||
firstName={conversation.firstName}
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
setActiveModal(undefined);
|
||||
}}
|
||||
title={conversation.title}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
modal = null;
|
||||
break;
|
||||
}
|
||||
|
||||
if (canEdit) {
|
||||
return (
|
||||
<div className={bem('root')}>
|
||||
{avatarLightbox}
|
||||
{modal}
|
||||
{avatar}
|
||||
<button
|
||||
type="button"
|
||||
|
@ -136,7 +181,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
|||
|
||||
return (
|
||||
<div className={bem('root')}>
|
||||
{avatarLightbox}
|
||||
{modal}
|
||||
{avatar}
|
||||
{contents}
|
||||
<div className={bem('subtitle')}>{subtitle}</div>
|
||||
|
|
|
@ -11,6 +11,9 @@ import { number } from '@storybook/addon-knobs';
|
|||
import { setupI18n } from '../../../util/setupI18n';
|
||||
import enMessages from '../../../../_locales/en/messages.json';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
import { getFakeBadge } from '../../../test-both/helpers/getFakeBadge';
|
||||
import { ThemeType } from '../../../types/Util';
|
||||
import type { BadgeType } from '../../../badges/types';
|
||||
|
||||
import type {
|
||||
Props,
|
||||
|
@ -47,8 +50,21 @@ const createProps = (overrideProps: Partial<Props>): Props => ({
|
|||
conversationId: '123',
|
||||
i18n,
|
||||
memberships: overrideProps.memberships || [],
|
||||
preferredBadgeByConversation:
|
||||
overrideProps.preferredBadgeByConversation ||
|
||||
(overrideProps.memberships || []).reduce(
|
||||
(result: Record<string, BadgeType>, { member }, index) =>
|
||||
(index + 1) % 3 === 0
|
||||
? {
|
||||
...result,
|
||||
[member.id]: getFakeBadge({ alternate: index % 2 !== 0 }),
|
||||
}
|
||||
: result,
|
||||
{}
|
||||
),
|
||||
showContactModal: action('showContactModal'),
|
||||
startAddingNewMembers: action('startAddingNewMembers'),
|
||||
theme: ThemeType.light,
|
||||
});
|
||||
|
||||
story.add('Few', () => {
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import type { LocalizerType } from '../../../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util';
|
||||
import { getOwn } from '../../../util/getOwn';
|
||||
|
||||
import type { BadgeType } from '../../../badges/types';
|
||||
import { Avatar } from '../../Avatar';
|
||||
import { Emojify } from '../Emojify';
|
||||
|
||||
|
@ -23,8 +26,10 @@ export type Props = {
|
|||
i18n: LocalizerType;
|
||||
maxShownMemberCount?: number;
|
||||
memberships: Array<GroupV2Membership>;
|
||||
preferredBadgeByConversation: Record<string, BadgeType>;
|
||||
showContactModal: (contactId: string, conversationId: string) => void;
|
||||
startAddingNewMembers?: () => void;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
|
@ -72,8 +77,10 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
|
|||
i18n,
|
||||
maxShownMemberCount = 5,
|
||||
memberships,
|
||||
preferredBadgeByConversation,
|
||||
showContactModal,
|
||||
startAddingNewMembers,
|
||||
theme,
|
||||
}) => {
|
||||
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
|
||||
const sortedMemberships = sortMemberships(memberships);
|
||||
|
@ -107,8 +114,10 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
|
|||
icon={
|
||||
<Avatar
|
||||
conversationType="direct"
|
||||
badge={getOwn(preferredBadgeByConversation, member.id)}
|
||||
i18n={i18n}
|
||||
size={32}
|
||||
theme={theme}
|
||||
{...member}
|
||||
/>
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ const sortedGroupMembers = Array.from(Array(32)).map((_, i) =>
|
|||
const conversation: ConversationType = {
|
||||
acceptedMessageRequest: true,
|
||||
areWeAdmin: true,
|
||||
badges: [],
|
||||
id: '',
|
||||
lastUpdated: 0,
|
||||
markedUnread: false,
|
||||
|
|
|
@ -8,10 +8,11 @@ import { isBoolean, isNumber } from 'lodash';
|
|||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { Avatar, AvatarSize } from '../Avatar';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import { Timestamp } from '../conversation/Timestamp';
|
||||
import { isConversationUnread } from '../../util/isConversationUnread';
|
||||
import { cleanId } from '../_util';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
|
||||
const BASE_CLASS_NAME =
|
||||
|
@ -27,6 +28,7 @@ export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
|
|||
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
||||
|
||||
type PropsType = {
|
||||
badge?: BadgeType;
|
||||
checked?: boolean;
|
||||
conversationType: 'group' | 'direct';
|
||||
disabled?: boolean;
|
||||
|
@ -42,6 +44,7 @@ type PropsType = {
|
|||
messageText?: ReactNode;
|
||||
messageTextIsAlwaysFullSize?: boolean;
|
||||
onClick?: () => void;
|
||||
theme?: ThemeType;
|
||||
unreadCount?: number;
|
||||
} & Pick<
|
||||
ConversationType,
|
||||
|
@ -62,6 +65,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
function BaseConversationListItem({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
badge,
|
||||
checked,
|
||||
color,
|
||||
conversationType,
|
||||
|
@ -82,6 +86,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
phoneNumber,
|
||||
profileName,
|
||||
sharedGroupNames,
|
||||
theme,
|
||||
title,
|
||||
unblurredAvatarPath,
|
||||
unreadCount,
|
||||
|
@ -129,6 +134,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
color={color}
|
||||
conversationType={conversationType}
|
||||
noteToSelf={isAvatarNoteToSelf}
|
||||
|
@ -137,6 +143,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.FORTY_EIGHT}
|
||||
|
|
|
@ -15,8 +15,9 @@ import { MessageBody } from '../conversation/MessageBody';
|
|||
import { ContactName } from '../conversation/ContactName';
|
||||
import { TypingAnimation } from '../conversation/TypingAnimation';
|
||||
|
||||
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';
|
||||
|
||||
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
|
||||
|
||||
|
@ -36,6 +37,7 @@ export type PropsData = Pick<
|
|||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'badges'
|
||||
| 'color'
|
||||
| 'draftPreview'
|
||||
| 'id'
|
||||
|
@ -56,11 +58,14 @@ export type PropsData = Pick<
|
|||
| 'typingContact'
|
||||
| 'unblurredAvatarPath'
|
||||
| 'unreadCount'
|
||||
>;
|
||||
> & {
|
||||
badge?: BadgeType;
|
||||
};
|
||||
|
||||
type PropsHousekeeping = {
|
||||
i18n: LocalizerType;
|
||||
onClick: (id: string) => void;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
export type Props = PropsData & PropsHousekeeping;
|
||||
|
@ -69,6 +74,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
function ConversationListItem({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
badge,
|
||||
color,
|
||||
draftPreview,
|
||||
i18n,
|
||||
|
@ -85,6 +91,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
shouldShowDraft,
|
||||
theme,
|
||||
title,
|
||||
type,
|
||||
typingContact,
|
||||
|
@ -163,6 +170,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
<BaseConversationListItem
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
color={color}
|
||||
conversationType={type}
|
||||
headerDate={lastUpdated}
|
||||
|
@ -180,6 +188,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
theme={theme}
|
||||
title={title}
|
||||
unreadCount={unreadCount}
|
||||
unblurredAvatarPath={unblurredAvatarPath}
|
||||
|
|
8
ts/model-types.d.ts
vendored
8
ts/model-types.d.ts
vendored
|
@ -208,6 +208,14 @@ export type ConversationAttributesTypeType = 'private' | 'group';
|
|||
export type ConversationAttributesType = {
|
||||
accessKey?: string | null;
|
||||
addedBy?: string;
|
||||
badges?: Array<
|
||||
| { id: string }
|
||||
| {
|
||||
id: string;
|
||||
expiresAt: number;
|
||||
isVisible: boolean;
|
||||
}
|
||||
>;
|
||||
capabilities?: CapabilitiesType;
|
||||
color?: string;
|
||||
conversationColor?: ConversationColorType;
|
||||
|
|
|
@ -1459,6 +1459,7 @@ export class ConversationModel extends window.Backbone
|
|||
),
|
||||
areWeAdmin: this.areWeAdmin(),
|
||||
avatars: getAvatarData(this.attributes),
|
||||
badges: this.get('badges') || [],
|
||||
canChangeTimer: this.canChangeTimer(),
|
||||
canEditGroupInfo: this.canEditGroupInfo(),
|
||||
avatarPath: this.getAbsoluteAvatarPath(),
|
||||
|
|
|
@ -34,6 +34,7 @@ import { cleanDataForIpc } from './cleanDataForIpc';
|
|||
import type { ReactionType } from '../types/Reactions';
|
||||
import type { ConversationColorType, CustomColorType } from '../types/Colors';
|
||||
import type { UUIDStringType } from '../types/UUID';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import type { ProcessGroupCallRingRequestResult } from '../types/Calling';
|
||||
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
||||
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
|
||||
|
@ -272,6 +273,10 @@ const dataInterface: ClientInterface = {
|
|||
updateEmojiUsage,
|
||||
getRecentEmojis,
|
||||
|
||||
getAllBadges,
|
||||
updateOrCreateBadges,
|
||||
badgeImageFileDownloaded,
|
||||
|
||||
removeAll,
|
||||
removeAllConfiguration,
|
||||
|
||||
|
@ -1575,6 +1580,27 @@ async function getRecentEmojis(limit = 32) {
|
|||
return channels.getRecentEmojis(limit);
|
||||
}
|
||||
|
||||
// Badges
|
||||
|
||||
function getAllBadges(): Promise<Array<BadgeType>> {
|
||||
return channels.getAllBadges();
|
||||
}
|
||||
|
||||
async function updateOrCreateBadges(
|
||||
badges: ReadonlyArray<BadgeType>
|
||||
): Promise<void> {
|
||||
if (badges.length) {
|
||||
await channels.updateOrCreateBadges(badges);
|
||||
}
|
||||
}
|
||||
|
||||
function badgeImageFileDownloaded(
|
||||
url: string,
|
||||
localPath: string
|
||||
): Promise<void> {
|
||||
return channels.badgeImageFileDownloaded(url, localPath);
|
||||
}
|
||||
|
||||
// Other
|
||||
|
||||
async function removeAll() {
|
||||
|
|
|
@ -21,6 +21,7 @@ import type { AttachmentType } from '../types/Attachment';
|
|||
import type { BodyRangesType } from '../types/Util';
|
||||
import type { QualifiedAddressStringType } from '../types/QualifiedAddress';
|
||||
import type { UUIDStringType } from '../types/UUID';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
|
||||
|
@ -446,6 +447,10 @@ export type DataInterface = {
|
|||
updateEmojiUsage: (shortName: string, timeUsed?: number) => Promise<void>;
|
||||
getRecentEmojis: (limit?: number) => Promise<Array<EmojiType>>;
|
||||
|
||||
getAllBadges(): Promise<Array<BadgeType>>;
|
||||
updateOrCreateBadges(badges: ReadonlyArray<BadgeType>): Promise<void>;
|
||||
badgeImageFileDownloaded(url: string, localPath: string): Promise<void>;
|
||||
|
||||
removeAll: () => Promise<void>;
|
||||
removeAllConfiguration: (type?: RemoveAllConfiguration) => Promise<void>;
|
||||
|
||||
|
@ -572,6 +577,7 @@ export type ServerInterface = DataInterface & {
|
|||
removeKnownDraftAttachments: (
|
||||
allStickers: Array<string>
|
||||
) => Promise<Array<string>>;
|
||||
getAllBadgeImageFileLocalPaths: () => Promise<Set<string>>;
|
||||
};
|
||||
|
||||
export type ClientInterface = DataInterface & {
|
||||
|
|
145
ts/sql/Server.ts
145
ts/sql/Server.ts
|
@ -44,6 +44,9 @@ import { formatCountForLogging } from '../logging/formatCountForLogging';
|
|||
import type { ConversationColorType, CustomColorType } from '../types/Colors';
|
||||
import { ProcessGroupCallRingRequestResult } from '../types/Calling';
|
||||
import { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
||||
import type { BadgeType, BadgeImageType } from '../badges/types';
|
||||
import { parseBadgeCategory } from '../badges/BadgeCategory';
|
||||
import { parseBadgeImageTheme } from '../badges/BadgeImageTheme';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import * as log from '../logging/log';
|
||||
import type { EmptyQuery, ArrayQuery, Query, JSONRows } from './util';
|
||||
|
@ -260,6 +263,10 @@ const dataInterface: ServerInterface = {
|
|||
updateEmojiUsage,
|
||||
getRecentEmojis,
|
||||
|
||||
getAllBadges,
|
||||
updateOrCreateBadges,
|
||||
badgeImageFileDownloaded,
|
||||
|
||||
removeAll,
|
||||
removeAllConfiguration,
|
||||
|
||||
|
@ -289,6 +296,7 @@ const dataInterface: ServerInterface = {
|
|||
removeKnownAttachments,
|
||||
removeKnownStickers,
|
||||
removeKnownDraftAttachments,
|
||||
getAllBadgeImageFileLocalPaths,
|
||||
};
|
||||
export default dataInterface;
|
||||
|
||||
|
@ -3571,12 +3579,149 @@ async function getRecentEmojis(limit = 32): Promise<Array<EmojiType>> {
|
|||
return rows || [];
|
||||
}
|
||||
|
||||
async function getAllBadges(): Promise<Array<BadgeType>> {
|
||||
const db = getInstance();
|
||||
|
||||
const [badgeRows, badgeImageFileRows] = db.transaction(() => [
|
||||
db.prepare<EmptyQuery>('SELECT * FROM badges').all(),
|
||||
db.prepare<EmptyQuery>('SELECT * FROM badgeImageFiles').all(),
|
||||
])();
|
||||
|
||||
const badgeImagesByBadge = new Map<
|
||||
string,
|
||||
Array<undefined | BadgeImageType>
|
||||
>();
|
||||
for (const badgeImageFileRow of badgeImageFileRows) {
|
||||
const { badgeId, order, localPath, url, theme } = badgeImageFileRow;
|
||||
const badgeImages = badgeImagesByBadge.get(badgeId) || [];
|
||||
badgeImages[order] = {
|
||||
...(badgeImages[order] || {}),
|
||||
[parseBadgeImageTheme(theme)]: {
|
||||
localPath: dropNull(localPath),
|
||||
url,
|
||||
},
|
||||
};
|
||||
badgeImagesByBadge.set(badgeId, badgeImages);
|
||||
}
|
||||
|
||||
return badgeRows.map(badgeRow => ({
|
||||
id: badgeRow.id,
|
||||
category: parseBadgeCategory(badgeRow.category),
|
||||
name: badgeRow.name,
|
||||
descriptionTemplate: badgeRow.descriptionTemplate,
|
||||
images: (badgeImagesByBadge.get(badgeRow.id) || []).filter(isNotNil),
|
||||
}));
|
||||
}
|
||||
|
||||
// This should match the logic in the badges Redux reducer.
|
||||
async function updateOrCreateBadges(
|
||||
badges: ReadonlyArray<BadgeType>
|
||||
): Promise<void> {
|
||||
const db = getInstance();
|
||||
|
||||
const insertBadge = prepare<Query>(
|
||||
db,
|
||||
`
|
||||
INSERT OR REPLACE INTO badges (
|
||||
id,
|
||||
category,
|
||||
name,
|
||||
descriptionTemplate
|
||||
) VALUES (
|
||||
$id,
|
||||
$category,
|
||||
$name,
|
||||
$descriptionTemplate
|
||||
);
|
||||
`
|
||||
);
|
||||
const getImageFilesForBadge = prepare<Query>(
|
||||
db,
|
||||
'SELECT url, localPath FROM badgeImageFiles WHERE badgeId = $badgeId'
|
||||
);
|
||||
const insertBadgeImageFile = prepare<Query>(
|
||||
db,
|
||||
`
|
||||
INSERT INTO badgeImageFiles (
|
||||
badgeId,
|
||||
'order',
|
||||
url,
|
||||
localPath,
|
||||
theme
|
||||
) VALUES (
|
||||
$badgeId,
|
||||
$order,
|
||||
$url,
|
||||
$localPath,
|
||||
$theme
|
||||
);
|
||||
`
|
||||
);
|
||||
|
||||
db.transaction(() => {
|
||||
badges.forEach(badge => {
|
||||
const { id: badgeId } = badge;
|
||||
|
||||
const oldLocalPaths = new Map<string, string>();
|
||||
for (const { url, localPath } of getImageFilesForBadge.all({ badgeId })) {
|
||||
if (localPath) {
|
||||
oldLocalPaths.set(url, localPath);
|
||||
}
|
||||
}
|
||||
|
||||
insertBadge.run({
|
||||
id: badgeId,
|
||||
category: badge.category,
|
||||
name: badge.name,
|
||||
descriptionTemplate: badge.descriptionTemplate,
|
||||
});
|
||||
|
||||
for (const [order, image] of badge.images.entries()) {
|
||||
for (const [theme, imageFile] of Object.entries(image)) {
|
||||
insertBadgeImageFile.run({
|
||||
badgeId,
|
||||
localPath:
|
||||
imageFile.localPath || oldLocalPaths.get(imageFile.url) || null,
|
||||
order,
|
||||
theme,
|
||||
url: imageFile.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
async function badgeImageFileDownloaded(
|
||||
url: string,
|
||||
localPath: string
|
||||
): Promise<void> {
|
||||
const db = getInstance();
|
||||
prepare<Query>(
|
||||
db,
|
||||
'UPDATE badgeImageFiles SET localPath = $localPath WHERE url = $url'
|
||||
).run({ url, localPath });
|
||||
}
|
||||
|
||||
async function getAllBadgeImageFileLocalPaths(): Promise<Set<string>> {
|
||||
const db = getInstance();
|
||||
const localPaths = db
|
||||
.prepare<EmptyQuery>(
|
||||
'SELECT localPath FROM badgeImageFiles WHERE localPath IS NOT NULL'
|
||||
)
|
||||
.pluck()
|
||||
.all();
|
||||
return new Set(localPaths);
|
||||
}
|
||||
|
||||
// All data in database
|
||||
async function removeAll(): Promise<void> {
|
||||
const db = getInstance();
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
DELETE FROM badges;
|
||||
DELETE FROM badgeImageFiles;
|
||||
DELETE FROM conversations;
|
||||
DELETE FROM identityKeys;
|
||||
DELETE FROM items;
|
||||
|
|
43
ts/sql/migrations/44-badges.ts
Normal file
43
ts/sql/migrations/44-badges.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
import type { LoggerType } from '../../types/Logging';
|
||||
|
||||
export default function updateToSchemaVersion44(
|
||||
currentVersion: number,
|
||||
db: Database,
|
||||
logger: LoggerType
|
||||
): void {
|
||||
if (currentVersion >= 44) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(
|
||||
`
|
||||
CREATE TABLE badges(
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
descriptionTemplate TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE badgeImageFiles(
|
||||
badgeId TEXT REFERENCES badges(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
'order' INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
localPath TEXT,
|
||||
theme TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
);
|
||||
|
||||
db.pragma('user_version = 44');
|
||||
})();
|
||||
|
||||
logger.info('updateToSchemaVersion44: success!');
|
||||
}
|
|
@ -19,6 +19,7 @@ import type { Query, EmptyQuery } from '../util';
|
|||
import updateToSchemaVersion41 from './41-uuid-keys';
|
||||
import updateToSchemaVersion42 from './42-stale-reactions';
|
||||
import updateToSchemaVersion43 from './43-gv2-uuid';
|
||||
import updateToSchemaVersion44 from './44-badges';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -1901,6 +1902,7 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion41,
|
||||
updateToSchemaVersion42,
|
||||
updateToSchemaVersion43,
|
||||
updateToSchemaVersion44,
|
||||
];
|
||||
|
||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { actions as accounts } from './ducks/accounts';
|
||||
import { actions as app } from './ducks/app';
|
||||
import { actions as audioPlayer } from './ducks/audioPlayer';
|
||||
import { actions as audioRecorder } from './ducks/audioRecorder';
|
||||
import { actions as badges } from './ducks/badges';
|
||||
import { actions as calling } from './ducks/calling';
|
||||
import { actions as composer } from './ducks/composer';
|
||||
import { actions as conversations } from './ducks/conversations';
|
||||
|
@ -26,6 +27,7 @@ export const actionCreators: ReduxActions = {
|
|||
app,
|
||||
audioPlayer,
|
||||
audioRecorder,
|
||||
badges,
|
||||
calling,
|
||||
composer,
|
||||
conversations,
|
||||
|
@ -47,6 +49,7 @@ export const mapDispatchToProps = {
|
|||
...app,
|
||||
...audioPlayer,
|
||||
...audioRecorder,
|
||||
...badges,
|
||||
...calling,
|
||||
...composer,
|
||||
...conversations,
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import { getUserLanguages } from '../../util/userLanguages';
|
||||
|
||||
import type { NoopActionType } from './noop';
|
||||
|
||||
|
@ -50,7 +51,12 @@ function checkForAccount(
|
|||
let hasAccount = false;
|
||||
|
||||
try {
|
||||
await window.textsecure.messaging.getProfile(identifier);
|
||||
await window.textsecure.messaging.getProfile(identifier, {
|
||||
userLanguages: getUserLanguages(
|
||||
navigator.languages,
|
||||
window.getLocale()
|
||||
),
|
||||
});
|
||||
hasAccount = true;
|
||||
} catch (_error) {
|
||||
// Doing nothing with this failed fetch
|
||||
|
|
157
ts/state/ducks/badges.ts
Normal file
157
ts/state/ducks/badges.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
import { mapValues } from 'lodash';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import type { BadgeType, BadgeImageType } from '../../badges/types';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { badgeImageFileDownloader } from '../../badges/badgeImageFileDownloader';
|
||||
|
||||
/**
|
||||
* This duck deals with badge data. Some assumptions it makes:
|
||||
*
|
||||
* - It should always be "behind" what's in the database. For example, the state should
|
||||
* never contain badges that aren't on disk.
|
||||
*
|
||||
* - There are under 100 unique badges. (As of today, there are ~5.) The performance
|
||||
* should be okay if there are more than 100, but it's not optimized for that. This
|
||||
* means we load all badges into memory, download image files as soon as we learn about
|
||||
* them, etc.
|
||||
*/
|
||||
|
||||
// State
|
||||
|
||||
export type BadgesStateType = {
|
||||
byId: Record<string, BadgeType>;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
const IMAGE_FILE_DOWNLOADED = 'badges/IMAGE_FILE_DOWNLOADED';
|
||||
const UPDATE_OR_CREATE = 'badges/UPDATE_OR_CREATE';
|
||||
|
||||
type ImageFileDownloadedActionType = {
|
||||
type: typeof IMAGE_FILE_DOWNLOADED;
|
||||
payload: {
|
||||
url: string;
|
||||
localPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UpdateOrCreateActionType = {
|
||||
type: typeof UPDATE_OR_CREATE;
|
||||
payload: ReadonlyArray<BadgeType>;
|
||||
};
|
||||
|
||||
// Action creators
|
||||
|
||||
export const actions = {
|
||||
badgeImageFileDownloaded,
|
||||
updateOrCreate,
|
||||
};
|
||||
|
||||
function badgeImageFileDownloaded(
|
||||
url: string,
|
||||
localPath: string
|
||||
): ImageFileDownloadedActionType {
|
||||
return {
|
||||
type: IMAGE_FILE_DOWNLOADED,
|
||||
payload: { url, localPath },
|
||||
};
|
||||
}
|
||||
|
||||
function updateOrCreate(
|
||||
badges: ReadonlyArray<BadgeType>
|
||||
): ThunkAction<void, RootStateType, unknown, UpdateOrCreateActionType> {
|
||||
return async dispatch => {
|
||||
// There is a race condition here: if we save the badges but we fail to kick off a
|
||||
// check (e.g., due to a crash), we won't download its image files. In the unlikely
|
||||
// event that this happens, we'll repair it the next time we check for undownloaded
|
||||
// image files.
|
||||
await window.Signal.Data.updateOrCreateBadges(badges);
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_OR_CREATE,
|
||||
payload: badges,
|
||||
});
|
||||
|
||||
badgeImageFileDownloader.checkForFilesToDownload();
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
export function getInitialState(): BadgesStateType {
|
||||
return { byId: {} };
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: Readonly<BadgesStateType> = getInitialState(),
|
||||
action: Readonly<ImageFileDownloadedActionType | UpdateOrCreateActionType>
|
||||
): BadgesStateType {
|
||||
switch (action.type) {
|
||||
// This should match the database logic.
|
||||
case IMAGE_FILE_DOWNLOADED: {
|
||||
const { url, localPath } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
byId: mapValues(state.byId, badge => ({
|
||||
...badge,
|
||||
images: badge.images.map(image =>
|
||||
mapValues(image, imageFile =>
|
||||
imageFile.url === url
|
||||
? {
|
||||
...imageFile,
|
||||
localPath,
|
||||
}
|
||||
: imageFile
|
||||
)
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// This should match the database logic.
|
||||
case UPDATE_OR_CREATE: {
|
||||
const newById = { ...state.byId };
|
||||
action.payload.forEach(badge => {
|
||||
const existingBadge = getOwn(newById, badge.id);
|
||||
|
||||
const oldLocalPaths = new Map<string, string>();
|
||||
existingBadge?.images.forEach(image => {
|
||||
Object.values(image).forEach(({ localPath, url }) => {
|
||||
if (localPath) {
|
||||
oldLocalPaths.set(url, localPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const images: ReadonlyArray<BadgeImageType> = badge.images.map(image =>
|
||||
mapValues(image, imageFile => ({
|
||||
...imageFile,
|
||||
localPath: imageFile.localPath || oldLocalPaths.get(imageFile.url),
|
||||
}))
|
||||
);
|
||||
|
||||
if (existingBadge) {
|
||||
newById[badge.id] = {
|
||||
...existingBadge,
|
||||
category: badge.category,
|
||||
name: badge.name,
|
||||
descriptionTemplate: badge.descriptionTemplate,
|
||||
images,
|
||||
};
|
||||
} else {
|
||||
newById[badge.id] = { ...badge, images };
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
byId: newById,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -198,6 +198,17 @@ export type ConversationType = {
|
|||
publicParams?: string;
|
||||
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
|
||||
profileKey?: string;
|
||||
|
||||
badges: Array<
|
||||
| {
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
expiresAt: number;
|
||||
isVisible: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
export type ProfileDataType = {
|
||||
firstName: string;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { reducer as accounts } from './ducks/accounts';
|
|||
import { reducer as app } from './ducks/app';
|
||||
import { reducer as audioPlayer } from './ducks/audioPlayer';
|
||||
import { reducer as audioRecorder } from './ducks/audioRecorder';
|
||||
import { reducer as badges } from './ducks/badges';
|
||||
import { reducer as calling } from './ducks/calling';
|
||||
import { reducer as composer } from './ducks/composer';
|
||||
import { reducer as conversations } from './ducks/conversations';
|
||||
|
@ -28,6 +29,7 @@ export const reducer = combineReducers({
|
|||
app,
|
||||
audioPlayer,
|
||||
audioRecorder,
|
||||
badges,
|
||||
calling,
|
||||
composer,
|
||||
conversations,
|
||||
|
|
71
ts/state/selectors/badges.ts
Normal file
71
ts/state/selectors/badges.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import { mapValues } from 'lodash';
|
||||
import * as log from '../../logging/log';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { BadgesStateType } from '../ducks/badges';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
|
||||
const getBadgeState = (state: Readonly<StateType>): BadgesStateType =>
|
||||
state.badges;
|
||||
|
||||
export const getBadgesById = createSelector(getBadgeState, state =>
|
||||
mapValues(state.byId, badge => ({
|
||||
...badge,
|
||||
images: badge.images.map(image =>
|
||||
mapValues(image, imageFile =>
|
||||
imageFile.localPath
|
||||
? {
|
||||
...imageFile,
|
||||
localPath: window.Signal.Migrations.getAbsoluteBadgeImageFilePath(
|
||||
imageFile.localPath
|
||||
),
|
||||
}
|
||||
: imageFile
|
||||
)
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
export const getBadgesSelector = createSelector(
|
||||
getBadgesById,
|
||||
badgesById => (
|
||||
conversationBadges: ReadonlyArray<Pick<BadgeType, 'id'>>
|
||||
): Array<BadgeType> => {
|
||||
const result: Array<BadgeType> = [];
|
||||
|
||||
for (const { id } of conversationBadges) {
|
||||
const badge = getOwn(badgesById, id);
|
||||
if (!badge) {
|
||||
log.error('getBadgesSelector: conversation badge was not found');
|
||||
continue;
|
||||
}
|
||||
result.push(badge);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
export const getPreferredBadgeSelector = createSelector(
|
||||
getBadgesById,
|
||||
badgesById => (
|
||||
conversationBadges: ReadonlyArray<Pick<BadgeType, 'id'>>
|
||||
): undefined | BadgeType => {
|
||||
const firstId: undefined | string = conversationBadges[0]?.id;
|
||||
if (!firstId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const badge = getOwn(badgesById, firstId);
|
||||
if (!badge) {
|
||||
log.error('getPreferredBadgeSelector: conversation badge was not found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
);
|
|
@ -61,6 +61,7 @@ export const getPlaceholderContact = (): ConversationType => {
|
|||
|
||||
placeholderContact = {
|
||||
acceptedMessageRequest: false,
|
||||
badges: [],
|
||||
id: 'placeholder-contact',
|
||||
type: 'direct',
|
||||
title: window.i18n('unknownContact'),
|
||||
|
|
|
@ -11,7 +11,7 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
|
|||
import { dropNull } from '../../util/dropNull';
|
||||
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import { getIntl, getUserConversationId } from '../selectors/user';
|
||||
import { getIntl, getTheme, getUserConversationId } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import {
|
||||
getConversationSelector,
|
||||
|
@ -83,6 +83,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
// Base
|
||||
conversationId: id,
|
||||
i18n: getIntl(state),
|
||||
theme: getTheme(state),
|
||||
// AudioCapture
|
||||
errorDialogAudioRecorderType:
|
||||
state.audioRecorder.errorDialogAudioRecorderType,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
@ -8,6 +8,7 @@ import { ContactModal } from '../../components/conversation/ContactModal';
|
|||
import type { StateType } from '../reducer';
|
||||
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getBadgesSelector } from '../selectors/badges';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
|
||||
const mapStateToProps = (state: StateType): PropsDataType => {
|
||||
|
@ -35,6 +36,7 @@ const mapStateToProps = (state: StateType): PropsDataType => {
|
|||
|
||||
return {
|
||||
areWeAdmin,
|
||||
badges: getBadgesSelector(state)(contact.badges),
|
||||
contact,
|
||||
conversationId,
|
||||
i18n: getIntl(state),
|
||||
|
|
|
@ -13,8 +13,13 @@ import {
|
|||
getConversationByUuidSelector,
|
||||
} from '../selectors/conversations';
|
||||
import { getGroupMemberships } from '../../util/getGroupMemberships';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getIntl, getTheme } from '../selectors/user';
|
||||
import type { MediaItemType } from '../../types/MediaItem';
|
||||
import {
|
||||
getBadgesSelector,
|
||||
getPreferredBadgeSelector,
|
||||
} from '../selectors/badges';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import { assert } from '../../util/assert';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
|
||||
|
@ -69,17 +74,36 @@ const mapStateToProps = (
|
|||
conversation.accessControlAddFromInviteLink !== ACCESS_ENUM.UNSATISFIABLE;
|
||||
|
||||
const conversationByUuidSelector = getConversationByUuidSelector(state);
|
||||
const groupMemberships = getGroupMemberships(
|
||||
conversation,
|
||||
conversationByUuidSelector
|
||||
);
|
||||
|
||||
const badges = getBadgesSelector(state)(conversation.badges);
|
||||
|
||||
const preferredBadgeByConversation: Record<string, BadgeType> = {};
|
||||
const getPreferredBadge = getPreferredBadgeSelector(state);
|
||||
groupMemberships.memberships.forEach(({ member }) => {
|
||||
const preferredBadge = getPreferredBadge(member.badges);
|
||||
if (preferredBadge) {
|
||||
preferredBadgeByConversation[member.id] = preferredBadge;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...props,
|
||||
badges,
|
||||
canEditGroupInfo,
|
||||
candidateContactsToAdd,
|
||||
conversation,
|
||||
i18n: getIntl(state),
|
||||
isAdmin,
|
||||
...getGroupMemberships(conversation, conversationByUuidSelector),
|
||||
preferredBadgeByConversation,
|
||||
...groupMemberships,
|
||||
userAvatarData: conversation.avatars || [],
|
||||
hasGroupLink,
|
||||
isGroup: conversation.type === 'group',
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
ConversationHeader,
|
||||
OutgoingCallButtonStyle,
|
||||
} from '../../components/conversation/ConversationHeader';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import {
|
||||
getConversationSelector,
|
||||
isMissingRequiredProfileSharing,
|
||||
|
@ -16,7 +17,7 @@ import { CallMode } from '../../types/Calling';
|
|||
import type { ConversationType } from '../ducks/conversations';
|
||||
import { getConversationCallMode } from '../ducks/conversations';
|
||||
import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling';
|
||||
import { getUserUuid, getIntl } from '../selectors/user';
|
||||
import { getUserUuid, getIntl, getTheme } from '../selectors/user';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
|
||||
|
@ -104,6 +105,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
|||
'type',
|
||||
'unblurredAvatarPath',
|
||||
]),
|
||||
badge: getPreferredBadgeSelector(state)(conversation.badges),
|
||||
conversationTitle: state.conversations.selectedConversationTitle,
|
||||
isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(
|
||||
conversation
|
||||
|
@ -112,6 +114,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
|||
i18n: getIntl(state),
|
||||
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
|
||||
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import type { BodyRangeType } from '../../types/Util';
|
|||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||
import { getAllComposableConversations } from '../selectors/conversations';
|
||||
import { getLinkPreview } from '../selectors/linkPreviews';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getIntl, getTheme } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
|
@ -66,6 +66,7 @@ const mapStateToProps = (
|
|||
recentEmojis,
|
||||
skinTone,
|
||||
onTextTooLong,
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
@ -7,7 +7,8 @@ import { mapDispatchToProps } from '../actions';
|
|||
import { ConversationHero } from '../../components/conversation/ConversationHero';
|
||||
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { getIntl, getTheme } from '../selectors/user';
|
||||
|
||||
type ExternalProps = {
|
||||
id: string;
|
||||
|
@ -26,6 +27,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
i18n: getIntl(state),
|
||||
...conversation,
|
||||
conversationType: conversation.type,
|
||||
badge: getPreferredBadgeSelector(state)(conversation.badges),
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -19,7 +19,8 @@ import {
|
|||
getStartSearchCounter,
|
||||
isSearching,
|
||||
} from '../selectors/search';
|
||||
import { getIntl, getRegionCode } from '../selectors/user';
|
||||
import { getIntl, getRegionCode, getTheme } from '../selectors/user';
|
||||
import { getBadgesById } from '../selectors/badges';
|
||||
import { getPreferredLeftPaneWidth } from '../selectors/items';
|
||||
import {
|
||||
getCantAddContactForModal,
|
||||
|
@ -159,6 +160,7 @@ const getModeSpecificProps = (
|
|||
const mapStateToProps = (state: StateType) => {
|
||||
return {
|
||||
modeSpecificProps: getModeSpecificProps(state),
|
||||
badgesById: getBadgesById(state),
|
||||
canResizeLeftPane: window.Signal.RemoteConfig.isEnabled(
|
||||
'desktop.internalUser'
|
||||
),
|
||||
|
@ -176,6 +178,7 @@ const mapStateToProps = (state: StateType) => {
|
|||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
renderCaptchaDialog,
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import type { actions as accounts } from './ducks/accounts';
|
|||
import type { actions as app } from './ducks/app';
|
||||
import type { actions as audioPlayer } from './ducks/audioPlayer';
|
||||
import type { actions as audioRecorder } from './ducks/audioRecorder';
|
||||
import type { actions as badges } from './ducks/badges';
|
||||
import type { actions as calling } from './ducks/calling';
|
||||
import type { actions as composer } from './ducks/composer';
|
||||
import type { actions as conversations } from './ducks/conversations';
|
||||
|
@ -25,6 +26,7 @@ export type ReduxActions = {
|
|||
app: typeof app;
|
||||
audioPlayer: typeof audioPlayer;
|
||||
audioRecorder: typeof audioRecorder;
|
||||
badges: typeof badges;
|
||||
calling: typeof calling;
|
||||
composer: typeof composer;
|
||||
conversations: typeof conversations;
|
||||
|
|
128
ts/test-both/badges/getBadgeImageFileLocalPath_test.ts
Normal file
128
ts/test-both/badges/getBadgeImageFileLocalPath_test.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { BadgeCategory } from '../../badges/BadgeCategory';
|
||||
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
|
||||
|
||||
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
|
||||
|
||||
describe('getBadgeImageFileLocalPath', () => {
|
||||
const image = (localPath?: string) => ({
|
||||
localPath,
|
||||
url: 'https://example.com/ignored.svg',
|
||||
});
|
||||
|
||||
const badge = {
|
||||
category: BadgeCategory.Donor,
|
||||
descriptionTemplate: 'foo bar',
|
||||
id: 'foo',
|
||||
images: ['small', 'medium', 'large', 'huge'].map(size => ({
|
||||
[BadgeImageTheme.Dark]: image(`/${size}-dark.svg`),
|
||||
[BadgeImageTheme.Light]: image(undefined),
|
||||
[BadgeImageTheme.Transparent]: image(`/${size}-trns.svg`),
|
||||
})),
|
||||
name: 'Test Badge',
|
||||
};
|
||||
|
||||
it('returns undefined if passed no badge', () => {
|
||||
const result = getBadgeImageFileLocalPath(
|
||||
undefined,
|
||||
123,
|
||||
BadgeImageTheme.Transparent
|
||||
);
|
||||
assert.isUndefined(result);
|
||||
});
|
||||
|
||||
it('returns the first image if passed a small size', () => {
|
||||
const darkResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
10,
|
||||
BadgeImageTheme.Dark
|
||||
);
|
||||
assert.strictEqual(darkResult, '/small-dark.svg');
|
||||
|
||||
const lightResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
11,
|
||||
BadgeImageTheme.Light
|
||||
);
|
||||
assert.isUndefined(lightResult);
|
||||
|
||||
const transparentResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
12,
|
||||
BadgeImageTheme.Transparent
|
||||
);
|
||||
assert.strictEqual(transparentResult, '/small-trns.svg');
|
||||
});
|
||||
|
||||
it('returns the second image if passed a size between 24 and 36', () => {
|
||||
const darkResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
24,
|
||||
BadgeImageTheme.Dark
|
||||
);
|
||||
assert.strictEqual(darkResult, '/medium-dark.svg');
|
||||
|
||||
const lightResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
30,
|
||||
BadgeImageTheme.Light
|
||||
);
|
||||
assert.isUndefined(lightResult);
|
||||
|
||||
const transparentResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
35,
|
||||
BadgeImageTheme.Transparent
|
||||
);
|
||||
assert.strictEqual(transparentResult, '/medium-trns.svg');
|
||||
});
|
||||
|
||||
it('returns the third image if passed a size between 36 and 160', () => {
|
||||
const darkResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
36,
|
||||
BadgeImageTheme.Dark
|
||||
);
|
||||
assert.strictEqual(darkResult, '/large-dark.svg');
|
||||
|
||||
const lightResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
100,
|
||||
BadgeImageTheme.Light
|
||||
);
|
||||
assert.isUndefined(lightResult);
|
||||
|
||||
const transparentResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
159,
|
||||
BadgeImageTheme.Transparent
|
||||
);
|
||||
assert.strictEqual(transparentResult, '/large-trns.svg');
|
||||
});
|
||||
|
||||
it('returns the last image if passed a size above 159', () => {
|
||||
const darkResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
160,
|
||||
BadgeImageTheme.Dark
|
||||
);
|
||||
assert.strictEqual(darkResult, '/huge-dark.svg');
|
||||
|
||||
const lightResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
200,
|
||||
BadgeImageTheme.Light
|
||||
);
|
||||
assert.isUndefined(lightResult);
|
||||
|
||||
const transparentResult = getBadgeImageFileLocalPath(
|
||||
badge,
|
||||
999,
|
||||
BadgeImageTheme.Transparent
|
||||
);
|
||||
assert.strictEqual(transparentResult, '/huge-trns.svg');
|
||||
});
|
||||
});
|
38
ts/test-both/badges/isBadgeImageFileUrlValid_test.ts
Normal file
38
ts/test-both/badges/isBadgeImageFileUrlValid_test.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { isBadgeImageFileUrlValid } from '../../badges/isBadgeImageFileUrlValid';
|
||||
|
||||
describe('isBadgeImageFileUrlValid', () => {
|
||||
const UPDATES_URL = 'https://updates2.signal.org/desktop';
|
||||
|
||||
it('returns false for invalid URLs', () => {
|
||||
['', 'uhh', 'http:'].forEach(url => {
|
||||
assert.isFalse(isBadgeImageFileUrlValid(url, UPDATES_URL));
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false if the URL doesn't start with the right prefix", () => {
|
||||
[
|
||||
'https://user:pass@updates2.signal.org/static/badges/foo',
|
||||
'https://signal.org/static/badges/foo',
|
||||
'https://updates.signal.org/static/badges/foo',
|
||||
'http://updates2.signal.org/static/badges/foo',
|
||||
'http://updates2.signal.org/static/badges/foo',
|
||||
].forEach(url => {
|
||||
assert.isFalse(isBadgeImageFileUrlValid(url, UPDATES_URL));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true for valid URLs', () => {
|
||||
[
|
||||
'https://updates2.signal.org/static/badges/foo',
|
||||
'https://updates2.signal.org/static/badges/foo.svg',
|
||||
'https://updates2.signal.org/static/badges/foo.txt',
|
||||
].forEach(url => {
|
||||
assert.isTrue(isBadgeImageFileUrlValid(url, UPDATES_URL));
|
||||
});
|
||||
});
|
||||
});
|
212
ts/test-both/badges/parseBadgesFromServer_test.ts
Normal file
212
ts/test-both/badges/parseBadgesFromServer_test.ts
Normal file
|
@ -0,0 +1,212 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { omit } from 'lodash';
|
||||
import { BadgeCategory } from '../../badges/BadgeCategory';
|
||||
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
|
||||
|
||||
import { parseBadgesFromServer } from '../../badges/parseBadgesFromServer';
|
||||
|
||||
describe('parseBadgesFromServer', () => {
|
||||
const UPDATES_URL = 'https://updates2.signal.org/desktop';
|
||||
|
||||
const validBadgeData = {
|
||||
id: 'fake-badge-id',
|
||||
category: 'donor',
|
||||
name: 'Cool Donor',
|
||||
description: 'Hello {short_name}',
|
||||
svg: 'huge badge.svg',
|
||||
svgs: ['small', 'medium', 'large'].map(size => ({
|
||||
dark: `${size} badge dark.svg`,
|
||||
light: `${size} badge light.svg`,
|
||||
transparent: `${size} badge transparent.svg`,
|
||||
})),
|
||||
};
|
||||
const validBadge = {
|
||||
id: validBadgeData.id,
|
||||
category: BadgeCategory.Donor,
|
||||
name: 'Cool Donor',
|
||||
descriptionTemplate: 'Hello {short_name}',
|
||||
images: [
|
||||
...['small', 'medium', 'large'].map(size => ({
|
||||
[BadgeImageTheme.Dark]: {
|
||||
url: `https://updates2.signal.org/static/badges/${size}%20badge%20dark.svg`,
|
||||
},
|
||||
[BadgeImageTheme.Light]: {
|
||||
url: `https://updates2.signal.org/static/badges/${size}%20badge%20light.svg`,
|
||||
},
|
||||
[BadgeImageTheme.Transparent]: {
|
||||
url: `https://updates2.signal.org/static/badges/${size}%20badge%20transparent.svg`,
|
||||
},
|
||||
})),
|
||||
{
|
||||
[BadgeImageTheme.Transparent]: {
|
||||
url: 'https://updates2.signal.org/static/badges/huge%20badge.svg',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('returns an empty array if passed a non-array', () => {
|
||||
[undefined, null, 'foo.svg', validBadgeData].forEach(input => {
|
||||
assert.isEmpty(parseBadgesFromServer(input, UPDATES_URL));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array if passed one', () => {
|
||||
assert.isEmpty(parseBadgesFromServer([], UPDATES_URL));
|
||||
});
|
||||
|
||||
it('parses valid badge data', () => {
|
||||
const input = [validBadgeData];
|
||||
assert.deepStrictEqual(parseBadgesFromServer(input, UPDATES_URL), [
|
||||
validBadge,
|
||||
]);
|
||||
});
|
||||
|
||||
it('only returns the first 1000 badges', () => {
|
||||
const input = Array(1234).fill(validBadgeData);
|
||||
assert.lengthOf(parseBadgesFromServer(input, UPDATES_URL), 1000);
|
||||
});
|
||||
|
||||
it('discards badges with invalid IDs', () => {
|
||||
[undefined, null, 123].forEach(id => {
|
||||
const invalidBadgeData = {
|
||||
...validBadgeData,
|
||||
name: 'Should be missing',
|
||||
id,
|
||||
};
|
||||
const input = [validBadgeData, invalidBadgeData];
|
||||
assert.deepStrictEqual(parseBadgesFromServer(input, UPDATES_URL), [
|
||||
validBadge,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('discards badges with invalid names', () => {
|
||||
[undefined, null, 123].forEach(name => {
|
||||
const invalidBadgeData = {
|
||||
...validBadgeData,
|
||||
description: 'Should be missing',
|
||||
name,
|
||||
};
|
||||
const input = [validBadgeData, invalidBadgeData];
|
||||
assert.deepStrictEqual(parseBadgesFromServer(input, UPDATES_URL), [
|
||||
validBadge,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('discards badges with invalid description templates', () => {
|
||||
[undefined, null, 123].forEach(description => {
|
||||
const invalidBadgeData = {
|
||||
...validBadgeData,
|
||||
name: 'Hello',
|
||||
description,
|
||||
};
|
||||
const input = [validBadgeData, invalidBadgeData];
|
||||
assert.deepStrictEqual(parseBadgesFromServer(input, UPDATES_URL), [
|
||||
validBadge,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('discards badges that lack a valid "huge" SVG', () => {
|
||||
const input = [
|
||||
validBadgeData,
|
||||
omit(validBadgeData, 'svg'),
|
||||
{ ...validBadgeData, svg: 123 },
|
||||
];
|
||||
assert.deepStrictEqual(parseBadgesFromServer(input, UPDATES_URL), [
|
||||
validBadge,
|
||||
]);
|
||||
});
|
||||
|
||||
it('discards badges that lack exactly 3 valid "normal" SVGs', () => {
|
||||
const input = [
|
||||
validBadgeData,
|
||||
omit(validBadgeData, 'svgs'),
|
||||
{ ...validBadgeData, svgs: 'bad!' },
|
||||
{ ...validBadgeData, svgs: [] },
|
||||
{
|
||||
...validBadgeData,
|
||||
svgs: validBadgeData.svgs.slice(0, 2),
|
||||
},
|
||||
{
|
||||
...validBadgeData,
|
||||
svgs: [{}, ...validBadgeData.svgs.slice(1)],
|
||||
},
|
||||
{
|
||||
...validBadgeData,
|
||||
svgs: [{ dark: 123 }, ...validBadgeData.svgs.slice(1)],
|
||||
},
|
||||
{
|
||||
...validBadgeData,
|
||||
svgs: [
|
||||
...validBadgeData.svgs,
|
||||
{
|
||||
dark: 'too.svg',
|
||||
light: 'many.svg',
|
||||
transparent: 'badges.svg',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(parseBadgesFromServer(input, UPDATES_URL), [
|
||||
validBadge,
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts "donor" to the Donor category', () => {
|
||||
const input = [validBadgeData];
|
||||
assert.strictEqual(
|
||||
parseBadgesFromServer(input, UPDATES_URL)[0]?.category,
|
||||
BadgeCategory.Donor
|
||||
);
|
||||
});
|
||||
|
||||
it('converts "other" to the Other category', () => {
|
||||
const input = [
|
||||
{
|
||||
...validBadgeData,
|
||||
category: 'other',
|
||||
},
|
||||
];
|
||||
assert.strictEqual(
|
||||
parseBadgesFromServer(input, UPDATES_URL)[0]?.category,
|
||||
BadgeCategory.Other
|
||||
);
|
||||
});
|
||||
|
||||
it('converts unexpected categories to Other', () => {
|
||||
const input = [
|
||||
{
|
||||
...validBadgeData,
|
||||
category: 'garbage',
|
||||
},
|
||||
];
|
||||
assert.strictEqual(
|
||||
parseBadgesFromServer(input, UPDATES_URL)[0]?.category,
|
||||
BadgeCategory.Other
|
||||
);
|
||||
});
|
||||
|
||||
it('parses your own badges', () => {
|
||||
const input = [
|
||||
{
|
||||
...validBadgeData,
|
||||
expiration: 1234,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const badge = parseBadgesFromServer(input, UPDATES_URL)[0];
|
||||
if (!badge || !('expiresAt' in badge) || !('isVisible' in badge)) {
|
||||
throw new Error('Badge is invalid');
|
||||
}
|
||||
|
||||
assert.strictEqual(badge.expiresAt, 1234 * 1000);
|
||||
assert.isTrue(badge.isVisible);
|
||||
});
|
||||
});
|
|
@ -325,6 +325,7 @@ export function getDefaultConversation(
|
|||
|
||||
return {
|
||||
acceptedMessageRequest: true,
|
||||
badges: [],
|
||||
e164: '+1300555000',
|
||||
color: getRandomColor(),
|
||||
firstName,
|
||||
|
|
42
ts/test-both/helpers/getFakeBadge.ts
Normal file
42
ts/test-both/helpers/getFakeBadge.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { times } from 'lodash';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import { BadgeCategory } from '../../badges/BadgeCategory';
|
||||
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
|
||||
import { repeat, zipObject } from '../../util/iterables';
|
||||
|
||||
export function getFakeBadge({
|
||||
alternate = false,
|
||||
id = 'test-badge',
|
||||
}: Readonly<{
|
||||
alternate?: boolean;
|
||||
id?: string;
|
||||
}> = {}): BadgeType {
|
||||
const imageFile = {
|
||||
localPath: `/fixtures/${alternate ? 'blue' : 'orange'}-heart.svg`,
|
||||
url: 'https://example.com/ignored.svg',
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
category: alternate ? BadgeCategory.Other : BadgeCategory.Donor,
|
||||
name: `Test Badge ${alternate ? 'B' : 'A'}`,
|
||||
descriptionTemplate: '{short_name} got this badge for no good reason',
|
||||
images: [
|
||||
...Array(3).fill(
|
||||
zipObject(Object.values(BadgeImageTheme), repeat(imageFile))
|
||||
),
|
||||
{ [BadgeImageTheme.Transparent]: imageFile },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const getFakeBadges = (count: number): Array<BadgeType> =>
|
||||
times(count, index =>
|
||||
getFakeBadge({
|
||||
alternate: index % 2 !== 0,
|
||||
id: `test-badge-${index}`,
|
||||
})
|
||||
);
|
114
ts/test-both/state/ducks/badges_test.ts
Normal file
114
ts/test-both/state/ducks/badges_test.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { getFakeBadge } from '../../helpers/getFakeBadge';
|
||||
import { repeat, zipObject } from '../../../util/iterables';
|
||||
import { BadgeImageTheme } from '../../../badges/BadgeImageTheme';
|
||||
|
||||
import type { BadgesStateType } from '../../../state/ducks/badges';
|
||||
import { actions, reducer } from '../../../state/ducks/badges';
|
||||
|
||||
describe('both/state/ducks/badges', () => {
|
||||
describe('badgeImageFileDownloaded', () => {
|
||||
const { badgeImageFileDownloaded } = actions;
|
||||
|
||||
it("does nothing if the URL isn't in the list of badges", () => {
|
||||
const state: BadgesStateType = {
|
||||
byId: { foo: getFakeBadge({ id: 'foo' }) },
|
||||
};
|
||||
const action = badgeImageFileDownloaded(
|
||||
'https://foo.example.com/image.svg',
|
||||
'/path/to/file.svg'
|
||||
);
|
||||
const result = reducer(state, action);
|
||||
|
||||
assert.deepStrictEqual(result, state);
|
||||
});
|
||||
|
||||
it('updates all badge image files with matching URLs', () => {
|
||||
const state: BadgesStateType = {
|
||||
byId: {
|
||||
badge1: {
|
||||
...getFakeBadge({ id: 'badge1' }),
|
||||
images: [
|
||||
...Array(3).fill(
|
||||
zipObject(
|
||||
Object.values(BadgeImageTheme),
|
||||
repeat({ url: 'https://example.com/a.svg' })
|
||||
)
|
||||
),
|
||||
{
|
||||
[BadgeImageTheme.Transparent]: {
|
||||
url: 'https://example.com/b.svg',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
badge2: getFakeBadge({ id: 'badge2' }),
|
||||
badge3: {
|
||||
...getFakeBadge({ id: 'badge3' }),
|
||||
images: Array(4).fill({
|
||||
[BadgeImageTheme.Dark]: {
|
||||
localPath: 'to be overridden',
|
||||
url: 'https://example.com/a.svg',
|
||||
},
|
||||
[BadgeImageTheme.Light]: {
|
||||
localPath: 'to be overridden',
|
||||
url: 'https://example.com/a.svg',
|
||||
},
|
||||
[BadgeImageTheme.Transparent]: {
|
||||
localPath: '/path/should/be/unchanged',
|
||||
url: 'https://example.com/b.svg',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
const action = badgeImageFileDownloaded(
|
||||
'https://example.com/a.svg',
|
||||
'/path/to/file.svg'
|
||||
);
|
||||
const result = reducer(state, action);
|
||||
|
||||
assert.deepStrictEqual(result.byId.badge1?.images, [
|
||||
...Array(3).fill(
|
||||
zipObject(
|
||||
Object.values(BadgeImageTheme),
|
||||
repeat({
|
||||
localPath: '/path/to/file.svg',
|
||||
url: 'https://example.com/a.svg',
|
||||
})
|
||||
)
|
||||
),
|
||||
{
|
||||
[BadgeImageTheme.Transparent]: {
|
||||
url: 'https://example.com/b.svg',
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(
|
||||
result.byId.badge2,
|
||||
getFakeBadge({ id: 'badge2' })
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
result.byId.badge3?.images,
|
||||
Array(4).fill({
|
||||
[BadgeImageTheme.Dark]: {
|
||||
localPath: '/path/to/file.svg',
|
||||
url: 'https://example.com/a.svg',
|
||||
},
|
||||
[BadgeImageTheme.Light]: {
|
||||
localPath: '/path/to/file.svg',
|
||||
url: 'https://example.com/a.svg',
|
||||
},
|
||||
[BadgeImageTheme.Transparent]: {
|
||||
localPath: '/path/should/be/unchanged',
|
||||
url: 'https://example.com/b.svg',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
77
ts/test-both/util/userLanguages_test.ts
Normal file
77
ts/test-both/util/userLanguages_test.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import {
|
||||
formatAcceptLanguageHeader,
|
||||
getUserLanguages,
|
||||
} from '../../util/userLanguages';
|
||||
|
||||
describe('user language utilities', () => {
|
||||
describe('formatAcceptLanguageHeader', () => {
|
||||
it('returns * if no languages are provided', () => {
|
||||
assert.strictEqual(formatAcceptLanguageHeader([]), '*');
|
||||
});
|
||||
|
||||
it('formats one provided language', () => {
|
||||
assert.strictEqual(formatAcceptLanguageHeader(['en-US']), 'en-US');
|
||||
});
|
||||
|
||||
it('formats three provided languages', () => {
|
||||
assert.strictEqual(
|
||||
formatAcceptLanguageHeader('abc'.split('')),
|
||||
'a, b;q=0.9, c;q=0.8'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats 10 provided languages', () => {
|
||||
assert.strictEqual(
|
||||
formatAcceptLanguageHeader('abcdefghij'.split('')),
|
||||
'a, b;q=0.9, c;q=0.8, d;q=0.7, e;q=0.6, f;q=0.5, g;q=0.4, h;q=0.3, i;q=0.2, j;q=0.1'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats 11 provided languages', () => {
|
||||
assert.strictEqual(
|
||||
formatAcceptLanguageHeader('abcdefghijk'.split('')),
|
||||
'a, b;q=0.9, c;q=0.8, d;q=0.7, e;q=0.6, f;q=0.5, g;q=0.4, h;q=0.3, i;q=0.2, j;q=0.1, k;q=0.09'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats 19 provided languages', () => {
|
||||
assert.strictEqual(
|
||||
formatAcceptLanguageHeader('abcdefghijklmnopqrs'.split('')),
|
||||
'a, b;q=0.9, c;q=0.8, d;q=0.7, e;q=0.6, f;q=0.5, g;q=0.4, h;q=0.3, i;q=0.2, j;q=0.1, k;q=0.09, l;q=0.08, m;q=0.07, n;q=0.06, o;q=0.05, p;q=0.04, q;q=0.03, r;q=0.02, s;q=0.01'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats 20 provided languages', () => {
|
||||
assert.strictEqual(
|
||||
formatAcceptLanguageHeader('abcdefghijklmnopqrst'.split('')),
|
||||
'a, b;q=0.9, c;q=0.8, d;q=0.7, e;q=0.6, f;q=0.5, g;q=0.4, h;q=0.3, i;q=0.2, j;q=0.1, k;q=0.09, l;q=0.08, m;q=0.07, n;q=0.06, o;q=0.05, p;q=0.04, q;q=0.03, r;q=0.02, s;q=0.01, t;q=0.009'
|
||||
);
|
||||
});
|
||||
|
||||
it('only formats the first 28 languages', () => {
|
||||
const result = formatAcceptLanguageHeader(
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
);
|
||||
assert.include(result, 'B;q=0.001');
|
||||
assert.notInclude(result, 'C');
|
||||
assert.notInclude(result, 'D');
|
||||
assert.notInclude(result, 'E');
|
||||
assert.notInclude(result, 'Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserLanguages', () => {
|
||||
it('returns the fallback if no languages are provided', () => {
|
||||
assert.deepEqual(getUserLanguages([], 'fallback'), ['fallback']);
|
||||
assert.deepEqual(getUserLanguages(undefined, 'fallback'), ['fallback']);
|
||||
});
|
||||
|
||||
it('returns the provided languages', () => {
|
||||
assert.deepEqual(getUserLanguages(['a', 'b', 'c'], 'x'), ['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -129,6 +129,7 @@ describe('state/selectors/messages', () => {
|
|||
isMe: false,
|
||||
sharedGroupNames: [],
|
||||
acceptedMessageRequest: true,
|
||||
badges: [],
|
||||
};
|
||||
|
||||
it('returns false for disabled v1 groups', () => {
|
||||
|
@ -240,6 +241,7 @@ describe('state/selectors/messages', () => {
|
|||
isMe: false,
|
||||
sharedGroupNames: [],
|
||||
acceptedMessageRequest: true,
|
||||
badges: [],
|
||||
};
|
||||
|
||||
it('returns false for disabled v1 groups', () => {
|
||||
|
|
|
@ -26,6 +26,7 @@ describe('encryptProfileData', () => {
|
|||
|
||||
// To satisfy TS
|
||||
acceptedMessageRequest: true,
|
||||
badges: [],
|
||||
id: '',
|
||||
isMe: true,
|
||||
sharedGroupNames: [],
|
||||
|
|
|
@ -240,6 +240,12 @@ describe('Attachments', () => {
|
|||
it('should return random file name with correct length', () => {
|
||||
assert.lengthOf(Attachments.createName(), NAME_LENGTH);
|
||||
});
|
||||
|
||||
it('can include a suffix', () => {
|
||||
const result = Attachments.createName('.txt');
|
||||
assert.lengthOf(result, NAME_LENGTH + '.txt'.length);
|
||||
assert(result.endsWith('.txt'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativePath', () => {
|
||||
|
|
|
@ -2041,7 +2041,8 @@ export default class MessageSender {
|
|||
accessKey?: string;
|
||||
profileKeyVersion?: string;
|
||||
profileKeyCredentialRequest?: string;
|
||||
}> = {}
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
}>
|
||||
): Promise<ReturnType<WebAPIType['getProfile']>> {
|
||||
const { accessKey } = options;
|
||||
|
||||
|
|
|
@ -23,9 +23,10 @@ import { v4 as getGuid } from 'uuid';
|
|||
import { z } from 'zod';
|
||||
import Long from 'long';
|
||||
|
||||
import { assert } from '../util/assert';
|
||||
import { assert, strictAssert } from '../util/assert';
|
||||
import * as durations from '../util/durations';
|
||||
import { getUserAgent } from '../util/getUserAgent';
|
||||
import { formatAcceptLanguageHeader } from '../util/userLanguages';
|
||||
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
||||
import type { SocketStatus } from '../types/SocketStatus';
|
||||
import { toLogFormat } from '../types/errors';
|
||||
|
@ -41,6 +42,7 @@ import {
|
|||
} from '../Crypto';
|
||||
import { calculateAgreement, generateKeyPair } from '../Curve';
|
||||
import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch';
|
||||
import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid';
|
||||
|
||||
import type {
|
||||
StorageServiceCallOptionsType,
|
||||
|
@ -55,6 +57,7 @@ import type MessageSender from './SendMessage';
|
|||
import type { WebAPICredentials, IRequestHandler } from './Types.d';
|
||||
import { handleStatusCode, translateError } from './Utils';
|
||||
import * as log from '../logging/log';
|
||||
import { maybeParseUrl } from '../util/url';
|
||||
|
||||
// Note: this will break some code that expects to be able to use err.response when a
|
||||
// web request fails, because it will force it to text. But it is very useful for
|
||||
|
@ -559,6 +562,7 @@ const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
|||
type InitializeOptionsType = {
|
||||
url: string;
|
||||
storageUrl: string;
|
||||
updatesUrl: string;
|
||||
directoryEnclaveId: string;
|
||||
directoryTrustAnchor: string;
|
||||
directoryUrl: string;
|
||||
|
@ -676,6 +680,7 @@ export type ProfileType = Readonly<{
|
|||
credential?: string;
|
||||
capabilities?: CapabilitiesType;
|
||||
paymentAddress?: string;
|
||||
badges?: unknown;
|
||||
}>;
|
||||
|
||||
export type GetIceServersResultType = Readonly<{
|
||||
|
@ -757,6 +762,7 @@ export type WebAPIType = {
|
|||
options: {
|
||||
profileKeyVersion?: string;
|
||||
profileKeyCredentialRequest?: string;
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
}
|
||||
) => Promise<ProfileType>;
|
||||
getProfileUnauth: (
|
||||
|
@ -765,8 +771,10 @@ export type WebAPIType = {
|
|||
accessKey: string;
|
||||
profileKeyVersion?: string;
|
||||
profileKeyCredentialRequest?: string;
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
}
|
||||
) => Promise<ProfileType>;
|
||||
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
|
||||
getProvisioningResource: (
|
||||
handler: IRequestHandler
|
||||
) => Promise<WebSocketResource>;
|
||||
|
@ -913,6 +921,7 @@ export type ProxiedRequestOptionsType = {
|
|||
export function initialize({
|
||||
url,
|
||||
storageUrl,
|
||||
updatesUrl,
|
||||
directoryEnclaveId,
|
||||
directoryTrustAnchor,
|
||||
directoryUrl,
|
||||
|
@ -928,6 +937,9 @@ export function initialize({
|
|||
if (!is.string(storageUrl)) {
|
||||
throw new Error('WebAPI.initialize: Invalid storageUrl');
|
||||
}
|
||||
if (!is.string(updatesUrl)) {
|
||||
throw new Error('WebAPI.initialize: Invalid updatesUrl');
|
||||
}
|
||||
if (!is.string(directoryEnclaveId)) {
|
||||
throw new Error('WebAPI.initialize: Invalid directory enclave id');
|
||||
}
|
||||
|
@ -1036,6 +1048,7 @@ export function initialize({
|
|||
getMyKeys,
|
||||
getProfile,
|
||||
getProfileUnauth,
|
||||
getBadgeImageFile,
|
||||
getProvisioningResource,
|
||||
getSenderCertificate,
|
||||
getSticker,
|
||||
|
@ -1315,9 +1328,14 @@ export function initialize({
|
|||
options: {
|
||||
profileKeyVersion?: string;
|
||||
profileKeyCredentialRequest?: string;
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
}
|
||||
) {
|
||||
const { profileKeyVersion, profileKeyCredentialRequest } = options;
|
||||
const {
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest,
|
||||
userLanguages,
|
||||
} = options;
|
||||
|
||||
return (await _ajax({
|
||||
call: 'profile',
|
||||
|
@ -1327,6 +1345,9 @@ export function initialize({
|
|||
profileKeyVersion,
|
||||
profileKeyCredentialRequest
|
||||
),
|
||||
headers: {
|
||||
'Accept-Language': formatAcceptLanguageHeader(userLanguages),
|
||||
},
|
||||
responseType: 'json',
|
||||
redactUrl: _createRedactor(
|
||||
identifier,
|
||||
|
@ -1359,12 +1380,14 @@ export function initialize({
|
|||
accessKey: string;
|
||||
profileKeyVersion?: string;
|
||||
profileKeyCredentialRequest?: string;
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
accessKey,
|
||||
profileKeyVersion,
|
||||
profileKeyCredentialRequest,
|
||||
userLanguages,
|
||||
} = options;
|
||||
|
||||
return (await _ajax({
|
||||
|
@ -1375,6 +1398,9 @@ export function initialize({
|
|||
profileKeyVersion,
|
||||
profileKeyCredentialRequest
|
||||
),
|
||||
headers: {
|
||||
'Accept-Language': formatAcceptLanguageHeader(userLanguages),
|
||||
},
|
||||
responseType: 'json',
|
||||
unauthenticated: true,
|
||||
accessKey,
|
||||
|
@ -1386,6 +1412,34 @@ export function initialize({
|
|||
})) as ProfileType;
|
||||
}
|
||||
|
||||
async function getBadgeImageFile(
|
||||
imageFileUrl: string
|
||||
): Promise<Uint8Array> {
|
||||
strictAssert(
|
||||
isBadgeImageFileUrlValid(imageFileUrl, updatesUrl),
|
||||
'getBadgeImageFile got an invalid URL. Was bad data saved?'
|
||||
);
|
||||
|
||||
return _outerAjax(imageFileUrl, {
|
||||
certificateAuthority,
|
||||
contentType: 'application/octet-stream',
|
||||
proxyUrl,
|
||||
responseType: 'bytes',
|
||||
timeout: 0,
|
||||
type: 'GET',
|
||||
redactUrl: (href: string) => {
|
||||
const parsedUrl = maybeParseUrl(href);
|
||||
if (!parsedUrl) {
|
||||
return href;
|
||||
}
|
||||
const { pathname } = parsedUrl;
|
||||
const pattern = RegExp(escapeRegExp(pathname), 'g');
|
||||
return href.replace(pattern, `[REDACTED]${pathname.slice(-3)}`);
|
||||
},
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
async function getAvatar(path: string) {
|
||||
// Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our
|
||||
// attachment CDN, it uses our self-signed certificate, so we pass it in.
|
||||
|
|
|
@ -9,6 +9,7 @@ import { isPathInside } from './isPathInside';
|
|||
|
||||
const PATH = 'attachments.noindex';
|
||||
const AVATAR_PATH = 'avatars.noindex';
|
||||
const BADGES_PATH = 'badges.noindex';
|
||||
const STICKER_PATH = 'stickers.noindex';
|
||||
const TEMP_PATH = 'temp';
|
||||
const DRAFT_PATH = 'drafts.noindex';
|
||||
|
@ -23,6 +24,7 @@ const createPathGetter = (subpath: string) => (
|
|||
};
|
||||
|
||||
export const getAvatarsPath = createPathGetter(AVATAR_PATH);
|
||||
export const getBadgesPath = createPathGetter(BADGES_PATH);
|
||||
export const getDraftPath = createPathGetter(DRAFT_PATH);
|
||||
export const getPath = createPathGetter(PATH);
|
||||
export const getStickersPath = createPathGetter(STICKER_PATH);
|
||||
|
|
|
@ -8,6 +8,7 @@ type FormattedContact = Partial<ConversationType> &
|
|||
Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'badges'
|
||||
| 'id'
|
||||
| 'isMe'
|
||||
| 'sharedGroupNames'
|
||||
|
@ -18,6 +19,7 @@ type FormattedContact = Partial<ConversationType> &
|
|||
|
||||
const PLACEHOLDER_CONTACT: FormattedContact = {
|
||||
acceptedMessageRequest: false,
|
||||
badges: [],
|
||||
id: 'placeholder-contact',
|
||||
isMe: false,
|
||||
sharedGroupNames: [],
|
||||
|
@ -47,6 +49,7 @@ export function findAndFormatContact(identifier?: string): FormattedContact {
|
|||
|
||||
return {
|
||||
acceptedMessageRequest: false,
|
||||
badges: [],
|
||||
id: 'phone-only',
|
||||
isMe: false,
|
||||
phoneNumber,
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
import { getSendOptions } from './getSendOptions';
|
||||
import { isMe } from './whatTypeOfConversation';
|
||||
import * as log from '../logging/log';
|
||||
import { getUserLanguages } from './userLanguages';
|
||||
import { parseBadgesFromServer } from '../badges/parseBadgesFromServer';
|
||||
|
||||
export async function getProfile(
|
||||
providedUuid?: string,
|
||||
|
@ -27,6 +29,11 @@ export async function getProfile(
|
|||
);
|
||||
}
|
||||
|
||||
const { updatesUrl } = window.SignalContext.config;
|
||||
if (typeof updatesUrl !== 'string') {
|
||||
throw new Error('getProfile expected updatesUrl to be a defined string');
|
||||
}
|
||||
|
||||
const id = window.ConversationController.ensureContactIds({
|
||||
uuid: providedUuid,
|
||||
e164: providedE164,
|
||||
|
@ -41,6 +48,11 @@ export async function getProfile(
|
|||
window.getServerPublicParams()
|
||||
);
|
||||
|
||||
const userLanguages = getUserLanguages(
|
||||
navigator.languages,
|
||||
window.getLocale()
|
||||
);
|
||||
|
||||
let profile;
|
||||
|
||||
try {
|
||||
|
@ -92,6 +104,7 @@ export async function getProfile(
|
|||
accessKey: getInfo.accessKey,
|
||||
profileKeyVersion: profileKeyVersionHex,
|
||||
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
||||
userLanguages,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 401 || error.code === 403) {
|
||||
|
@ -102,6 +115,7 @@ export async function getProfile(
|
|||
profile = await window.textsecure.messaging.getProfile(identifier, {
|
||||
profileKeyVersion: profileKeyVersionHex,
|
||||
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
||||
userLanguages,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
|
@ -111,6 +125,7 @@ export async function getProfile(
|
|||
profile = await window.textsecure.messaging.getProfile(identifier, {
|
||||
profileKeyVersion: profileKeyVersionHex,
|
||||
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
||||
userLanguages,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -214,6 +229,24 @@ export async function getProfile(
|
|||
c.unset('capabilities');
|
||||
}
|
||||
|
||||
const badges = parseBadgesFromServer(profile.badges, updatesUrl);
|
||||
if (badges.length) {
|
||||
await window.reduxActions.badges.updateOrCreate(badges);
|
||||
c.set({
|
||||
badges: badges.map(badge => ({
|
||||
id: badge.id,
|
||||
...('expiresAt' in badge
|
||||
? {
|
||||
expiresAt: badge.expiresAt,
|
||||
isVisible: badge.isVisible,
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
c.unset('badges');
|
||||
}
|
||||
|
||||
if (profileCredentialRequestContext) {
|
||||
if (profile.credential) {
|
||||
const profileKeyCredential = handleProfileKeyCredential(
|
||||
|
|
53
ts/util/userLanguages.ts
Normal file
53
ts/util/userLanguages.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// We ["MUST NOT generate more than three digits after the decimal point"][0]. We use a
|
||||
// space-efficient algorithm that runs out of digits after 28 languages. This should be
|
||||
// fine for most users and [the server doesn't parse more than 15 languages, at least
|
||||
// for badges][1].
|
||||
//
|
||||
// [0]: https://httpwg.org/specs/rfc7231.html#quality.values
|
||||
// [1]: https://github.com/signalapp/Signal-Server/blob/d2bc3c736080c3d852c9e88af0bffcb6632d9975/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java#L29
|
||||
const MAX_LANGUAGES_TO_FORMAT = 28;
|
||||
|
||||
export function formatAcceptLanguageHeader(
|
||||
languages: ReadonlyArray<string>
|
||||
): string {
|
||||
if (languages.length === 0) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
const result: Array<string> = [];
|
||||
|
||||
const length = Math.min(languages.length, MAX_LANGUAGES_TO_FORMAT);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const language = languages[i];
|
||||
|
||||
// ["If no 'q' parameter is present, the default weight is 1."][1]
|
||||
//
|
||||
// [1]: https://httpwg.org/specs/rfc7231.html#quality.values
|
||||
if (i === 0) {
|
||||
result.push(language);
|
||||
continue;
|
||||
}
|
||||
|
||||
// These values compute a descending sequence with minimal bytes. See the tests for
|
||||
// examples.
|
||||
const magnitude = 1 / 10 ** (Math.ceil(i / 9) - 1);
|
||||
const subtractor = (((i - 1) % 9) + 1) * (magnitude / 10);
|
||||
const q = magnitude - subtractor;
|
||||
const formattedQ = q.toFixed(3).replace(/0+$/, '');
|
||||
|
||||
result.push(`${language};q=${formattedQ}`);
|
||||
}
|
||||
|
||||
return result.join(', ');
|
||||
}
|
||||
|
||||
export function getUserLanguages(
|
||||
defaults: undefined | ReadonlyArray<string>,
|
||||
fallback: string
|
||||
): ReadonlyArray<string> {
|
||||
const result = defaults || [];
|
||||
return result.length ? result : [fallback];
|
||||
}
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -348,6 +348,8 @@ declare global {
|
|||
deleteAvatar: (path: string) => Promise<void>;
|
||||
getAbsoluteAvatarPath: (src: string) => string;
|
||||
writeNewAvatarData: (data: Uint8Array) => Promise<string>;
|
||||
getAbsoluteBadgeImageFilePath: (path: string) => string;
|
||||
writeNewBadgeImageFileData: (data: Uint8Array) => Promise<string>;
|
||||
};
|
||||
Types: {
|
||||
Message: {
|
||||
|
|
|
@ -60,10 +60,8 @@ export const getRelativePath = (name: string): string => {
|
|||
return join(prefix, name);
|
||||
};
|
||||
|
||||
export const createName = (): string => {
|
||||
const buffer = getRandomBytes(32);
|
||||
return Bytes.toHex(buffer);
|
||||
};
|
||||
export const createName = (suffix = ''): string =>
|
||||
`${Bytes.toHex(getRandomBytes(32))}${suffix}`;
|
||||
|
||||
export const copyIntoAttachmentsDirectory = (
|
||||
root: string
|
||||
|
@ -107,7 +105,8 @@ export const copyIntoAttachmentsDirectory = (
|
|||
};
|
||||
|
||||
export const createWriterForNew = (
|
||||
root: string
|
||||
root: string,
|
||||
suffix?: string
|
||||
): ((bytes: Uint8Array) => Promise<string>) => {
|
||||
if (!isString(root)) {
|
||||
throw new TypeError("'root' must be a path");
|
||||
|
@ -118,7 +117,7 @@ export const createWriterForNew = (
|
|||
throw new TypeError("'bytes' must be a typed array");
|
||||
}
|
||||
|
||||
const name = createName();
|
||||
const name = createName(suffix);
|
||||
const relativePath = getRelativePath(name);
|
||||
return createWriterForExisting(root)({
|
||||
data: bytes,
|
||||
|
|
Loading…
Add table
Reference in a new issue