Display user badges

This commit is contained in:
Evan Hahn 2021-11-02 18:01:13 -05:00 committed by GitHub
parent 927c22ef73
commit f647c4e053
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 2891 additions and 424 deletions

View File

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

View File

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

View 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

View File

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

View File

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

View File

@ -105,4 +105,11 @@
&__spinner-container {
padding: 4px;
}
&__badge {
bottom: 0;
position: absolute;
right: 0;
z-index: 1;
}
}

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

View 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);
}
}

View File

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

View File

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

View 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
);

View 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
);

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

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

View 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));
}

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

View File

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

View File

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

View File

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

View 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>
);
}

View 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'}
/>
));

View 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}</>;
}

View 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)} />
));

View 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>
);
}

View 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,
}}
/>
);
}

View File

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

View File

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

View File

@ -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,
},
])}
]}
/>
));

View File

@ -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,
]
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View 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!');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
);

View File

@ -61,6 +61,7 @@ export const getPlaceholderContact = (): ConversationType => {
placeholderContact = {
acceptedMessageRequest: false,
badges: [],
id: 'placeholder-contact',
type: 'direct',
title: window.i18n('unknownContact'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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');
});
});

View 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));
});
});
});

View 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);
});
});

View File

@ -325,6 +325,7 @@ export function getDefaultConversation(
return {
acceptedMessageRequest: true,
badges: [],
e164: '+1300555000',
color: getRandomColor(),
firstName,

View 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}`,
})
);

View 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',
},
})
);
});
});
});

View 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']);
});
});
});

View File

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

View File

@ -26,6 +26,7 @@ describe('encryptProfileData', () => {
// To satisfy TS
acceptedMessageRequest: true,
badges: [],
id: '',
isMe: true,
sharedGroupNames: [],

View File

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

View File

@ -2041,7 +2041,8 @@ export default class MessageSender {
accessKey?: string;
profileKeyVersion?: string;
profileKeyCredentialRequest?: string;
}> = {}
userLanguages: ReadonlyArray<string>;
}>
): Promise<ReturnType<WebAPIType['getProfile']>> {
const { accessKey } = options;

View File

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

View File

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

View File

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

View File

@ -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
View 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
View File

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

View File

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