Co-authored-by: scott@signal.org
Co-authored-by: ken@signal.org
This commit is contained in:
Ken Powers 2019-05-16 15:32:11 -07:00 committed by Scott Nonnenberg
parent 8c8856785b
commit 29de50c12a
100 changed files with 7572 additions and 693 deletions

View file

@ -0,0 +1,16 @@
#### All Options
```jsx
<util.ConversationContext theme={util.theme}>
<ConfirmationDialog
i18n={util.i18n}
onClose={() => console.log('onClose')}
onAffirmative={() => console.log('onAffirmative')}
affirmativeText="Affirm"
onNegative={() => console.log('onNegative')}
negativeText="Negate"
>
asdf child
</ConfirmationDialog>
</util.ConversationContext>
```

View file

@ -0,0 +1,117 @@
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly children: React.ReactNode;
readonly affirmativeText?: string;
readonly onAffirmative?: () => unknown;
readonly onClose: () => unknown;
readonly negativeText?: string;
readonly onNegative?: () => unknown;
};
export type Props = OwnProps;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const ConfirmationDialog = React.memo(
({
i18n,
onClose,
children,
onAffirmative,
onNegative,
affirmativeText,
negativeText,
}: Props) => {
React.useEffect(
() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
onClose();
}
};
document.addEventListener('keyup', handler);
return () => {
document.removeEventListener('keyup', handler);
};
},
[onClose]
);
const handleCancel = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
const handleNegative = React.useCallback(
() => {
onClose();
if (onNegative) {
onNegative();
}
},
[onClose, onNegative]
);
const handleAffirmative = React.useCallback(
() => {
onClose();
if (onAffirmative) {
onAffirmative();
}
},
[onClose, onAffirmative]
);
return (
<div className="module-confirmation-dialog__container">
<div className="module-confirmation-dialog__container__content">
{children}
</div>
<div className="module-confirmation-dialog__container__buttons">
<button
onClick={handleCancel}
ref={focusRef}
className="module-confirmation-dialog__container__buttons__button"
>
{i18n('confirmation-dialog--Cancel')}
</button>
{onNegative && negativeText ? (
<button
onClick={handleNegative}
className={classNames(
'module-confirmation-dialog__container__buttons__button',
'module-confirmation-dialog__container__buttons__button--negative'
)}
>
{negativeText}
</button>
) : null}
{onAffirmative && affirmativeText ? (
<button
onClick={handleAffirmative}
className={classNames(
'module-confirmation-dialog__container__buttons__button',
'module-confirmation-dialog__container__buttons__button--affirmative'
)}
>
{affirmativeText}
</button>
) : null}
</div>
</div>
);
}
);

View file

@ -0,0 +1,89 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { ConfirmationDialog } from './ConfirmationDialog';
import { LocalizerType } from '../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly children: React.ReactNode;
readonly affirmativeText?: string;
readonly onAffirmative?: () => unknown;
readonly onClose: () => unknown;
readonly negativeText?: string;
readonly onNegative?: () => unknown;
};
export type Props = OwnProps;
export const ConfirmationModal = React.memo(
// tslint:disable-next-line max-func-body-length
({
i18n,
onClose,
children,
onAffirmative,
onNegative,
affirmativeText,
negativeText,
}: Props) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
React.useEffect(
() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
onClose();
}
};
document.addEventListener('keyup', handler);
return () => {
document.removeEventListener('keyup', handler);
};
},
[onClose]
);
const handleCancel = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
return root
? createPortal(
<div
role="button"
className="module-confirmation-dialog__overlay"
onClick={handleCancel}
>
<ConfirmationDialog
i18n={i18n}
onClose={onClose}
onAffirmative={onAffirmative}
onNegative={onNegative}
affirmativeText={affirmativeText}
negativeText={negativeText}
>
{children}
</ConfirmationDialog>
</div>,
root
)
: null;
}
);

View file

@ -5,9 +5,10 @@ import { getIncrement, getTimerBucket } from '../../util/timer';
interface Props {
withImageNoCaption: boolean;
withSticker: boolean;
expirationLength: number;
expirationTimestamp: number;
direction: 'incoming' | 'outgoing';
direction?: 'incoming' | 'outgoing';
}
export class ExpireTimer extends React.Component<Props> {
@ -44,6 +45,7 @@ export class ExpireTimer extends React.Component<Props> {
expirationLength,
expirationTimestamp,
withImageNoCaption,
withSticker,
} = this.props;
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
@ -56,7 +58,8 @@ export class ExpireTimer extends React.Component<Props> {
`module-expire-timer--${direction}`,
withImageNoCaption
? 'module-expire-timer--with-image-no-caption'
: null
: null,
withSticker ? 'module-expire-timer--with-sticker' : null
)}
/>
);

View file

@ -418,3 +418,51 @@
</div>
</util.ConversationContext>
```
### No border, no background
```jsx
<util.ConversationContext theme={util.theme}>
<div style={{ padding: '10px', backgroundColor: 'lightgrey' }}>
<div>
<Image
height="512"
width="512"
noBorder={true}
noBackground={true}
attachment={{}}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
url={util.squareStickerObjectUrl}
i18n={util.i18n}
/>
</div>
<div>
<Image
height="256"
width="256"
noBorder={true}
noBackground={true}
attachment={{}}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
url={util.squareStickerObjectUrl}
i18n={util.i18n}
/>
</div>
<div>
<Image
height="128"
width="128"
noBorder={true}
noBackground={true}
attachment={{}}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
url={util.squareStickerObjectUrl}
i18n={util.i18n}
/>
</div>
</div>
</util.ConversationContext>
```

View file

@ -15,6 +15,8 @@ interface Props {
overlayText?: string;
noBorder?: boolean;
noBackground?: boolean;
bottomOverlay?: boolean;
closeButton?: boolean;
curveBottomLeft?: boolean;
@ -49,6 +51,8 @@ export class Image extends React.Component<Props> {
darkOverlay,
height,
i18n,
noBackground,
noBorder,
onClick,
onClickClose,
onError,
@ -74,6 +78,7 @@ export class Image extends React.Component<Props> {
}}
className={classNames(
'module-image',
!noBackground ? 'module-image--with-background' : null,
canClick ? 'module-image__with-click-handler' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
@ -113,18 +118,20 @@ export class Image extends React.Component<Props> {
alt={i18n('imageCaptionIconAlt')}
/>
) : null}
<div
className={classNames(
'module-image__border-overlay',
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null
)}
/>
{!noBorder ? (
<div
className={classNames(
'module-image__border-overlay',
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null
)}
/>
) : null}
{closeButton ? (
<div
role="button"

View file

@ -384,3 +384,26 @@ const attachments = [
</div>
</div>;
```
### Sticker
```
const attachments = [
{
url: util.squareStickerObjectUrl,
contentType: 'image/webp',
width: 512,
height: 512,
},
];
<div>
<div>
<ImageGrid isSticker={true} stickerSize={128} attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid isSticker={true} stickerSize={128} withContentAbove withContentBelow attachments={attachments} i18n={util.i18n} />
</div>
</div>;
```

View file

@ -20,6 +20,8 @@ interface Props {
withContentAbove?: boolean;
withContentBelow?: boolean;
bottomOverlay?: boolean;
isSticker?: boolean;
stickerSize?: number;
i18n: LocalizerType;
@ -34,6 +36,8 @@ export class ImageGrid extends React.Component<Props> {
attachments,
bottomOverlay,
i18n,
isSticker,
stickerSize,
onError,
onClick,
withContentAbove,
@ -56,25 +60,31 @@ export class ImageGrid extends React.Component<Props> {
if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) {
const { height, width } = getImageDimensions(attachments[0]);
const finalHeight = isSticker ? stickerSize : height;
const finalWidth = isSticker ? stickerSize : width;
return (
<div
className={classNames(
'module-image-grid',
'module-image-grid--one-image'
'module-image-grid--one-image',
isSticker ? 'module-image-grid--with-sticker' : null
)}
>
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
noBackground={isSticker}
curveTopLeft={curveTopLeft}
curveTopRight={curveTopRight}
curveBottomLeft={curveBottomLeft}
curveBottomRight={curveBottomRight}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={height}
width={width}
height={finalHeight}
width={finalWidth}
url={getUrl(attachments[0])}
onClick={onClick}
onError={onError}
@ -91,6 +101,7 @@ export class ImageGrid extends React.Component<Props> {
i18n={i18n}
attachment={attachments[0]}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveTopLeft={curveTopLeft}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[0])}
@ -104,6 +115,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveTopRight={curveTopRight}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[1])}
@ -125,6 +137,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveTopLeft={curveTopLeft}
curveBottomLeft={curveBottomLeft}
attachment={attachments[0]}
@ -152,6 +165,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomRight={curveBottomRight}
height={99}
width={99}
@ -201,6 +215,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[2])}
height={149}
@ -214,6 +229,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[3])}
height={149}
@ -268,6 +284,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[2])}
height={99}
@ -281,6 +298,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
playIconOverlay={isVideoAttachment(attachments[3])}
height={99}
width={98}
@ -293,6 +311,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[4], i18n)}
i18n={i18n}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[4])}
height={99}

File diff suppressed because it is too large Load diff

View file

@ -39,11 +39,13 @@ interface Trigger {
// Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const STICKER_SIZE = 128;
interface LinkPreviewType {
title: string;
domain: string;
url: string;
isStickerPack: boolean;
image?: AttachmentType;
}
@ -51,6 +53,7 @@ export type PropsData = {
id: string;
text?: string;
textPending?: boolean;
isSticker: boolean;
direction: 'incoming' | 'outgoing';
timestamp: number;
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
@ -223,6 +226,7 @@ export class Message extends React.PureComponent<Props, State> {
expirationLength,
expirationTimestamp,
i18n,
isSticker,
status,
text,
textPending,
@ -234,8 +238,9 @@ export class Message extends React.PureComponent<Props, State> {
}
const isShowingImage = this.isShowingImage();
const withImageNoCaption = Boolean(!text && isShowingImage);
const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage);
const showError = status === 'error' && direction === 'outgoing';
const metadataDirection = isSticker ? undefined : direction;
return (
<div
@ -250,7 +255,10 @@ export class Message extends React.PureComponent<Props, State> {
<span
className={classNames(
'module-message__metadata__date',
`module-message__metadata__date--${direction}`,
isSticker ? 'module-message__metadata__date--with-sticker' : null,
!isSticker
? `module-message__metadata__date--${direction}`
: null,
withImageNoCaption
? 'module-message__metadata__date--with-image-no-caption'
: null
@ -263,17 +271,19 @@ export class Message extends React.PureComponent<Props, State> {
i18n={i18n}
timestamp={timestamp}
extended={true}
direction={direction}
direction={metadataDirection}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
module="module-message__metadata__date"
/>
)}
{expirationLength && expirationTimestamp ? (
<ExpireTimer
direction={direction}
direction={metadataDirection}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
/>
) : null}
<span className="module-message__metadata__spacer" />
@ -287,6 +297,9 @@ export class Message extends React.PureComponent<Props, State> {
className={classNames(
'module-message__metadata__status-icon',
`module-message__metadata__status-icon--${status}`,
isSticker
? 'module-message__metadata__status-icon--with-sticker'
: null,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null
@ -302,24 +315,33 @@ export class Message extends React.PureComponent<Props, State> {
authorName,
authorPhoneNumber,
authorProfileName,
collapseMetadata,
conversationType,
direction,
i18n,
isSticker,
} = this.props;
if (collapseMetadata) {
return;
}
const title = authorName ? authorName : authorPhoneNumber;
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
return null;
}
const suffix = isSticker ? '_with_sticker' : '';
const moduleName = `module-message__author${suffix}`;
return (
<div className="module-message__author">
<div className={moduleName}>
<ContactName
phoneNumber={authorPhoneNumber}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
module={moduleName}
i18n={i18n}
/>
</div>
@ -329,15 +351,16 @@ export class Message extends React.PureComponent<Props, State> {
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
public renderAttachment() {
const {
id,
attachments,
text,
collapseMetadata,
conversationType,
direction,
i18n,
id,
quote,
showVisualAttachment,
isSticker,
text,
} = this.props;
const { imageBroken } = this.state;
@ -359,23 +382,31 @@ export class Message extends React.PureComponent<Props, State> {
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
) {
const prefix = isSticker ? 'sticker' : 'attachment';
const bottomOverlay = !isSticker && !collapseMetadata;
return (
<div
className={classNames(
'module-message__attachment-container',
`module-message__${prefix}-container`,
withContentAbove
? 'module-message__attachment-container--with-content-above'
? `module-message__${prefix}-container--with-content-above`
: null,
withContentBelow
? 'module-message__attachment-container--with-content-below'
: null,
isSticker && !collapseMetadata
? 'module-message__sticker-container--with-content-below'
: null
)}
>
<ImageGrid
attachments={attachments}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
bottomOverlay={!collapseMetadata}
withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow}
isSticker={isSticker}
stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay}
i18n={i18n}
onError={this.handleImageErrorBound}
onClick={attachment => {
@ -494,7 +525,10 @@ export class Message extends React.PureComponent<Props, State> {
const previewHasImage = first.image && isImageAttachment(first.image);
const width = first.image && first.image.width;
const isFullSizeImage = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
const isFullSizeImage =
!first.isStickerPack &&
width &&
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
return (
<div
@ -768,6 +802,7 @@ export class Message extends React.PureComponent<Props, State> {
disableMenu,
downloadAttachment,
id,
isSticker,
replyToMessage,
timestamp,
} = this.props;
@ -783,7 +818,10 @@ export class Message extends React.PureComponent<Props, State> {
const firstAttachment = attachments && attachments[0];
const downloadButton =
!multipleAttachments && firstAttachment && !firstAttachment.pending ? (
!isSticker &&
!multipleAttachments &&
firstAttachment &&
!firstAttachment.pending ? (
<div
onClick={() => {
downloadAttachment({
@ -850,6 +888,7 @@ export class Message extends React.PureComponent<Props, State> {
downloadAttachment,
i18n,
id,
isSticker,
deleteMessage,
showMessageDetail,
replyToMessage,
@ -866,7 +905,7 @@ export class Message extends React.PureComponent<Props, State> {
const menu = (
<ContextMenu id={triggerId}>
{!multipleAttachments && attachments && attachments[0] ? (
{!isSticker && !multipleAttachments && attachments && attachments[0] ? (
<MenuItem
attributes={{
className: 'module-message__context__download',
@ -931,9 +970,14 @@ export class Message extends React.PureComponent<Props, State> {
}
public getWidth(): number | undefined {
const { attachments, previews } = this.props;
const { attachments, isSticker, previews } = this.props;
if (attachments && attachments.length) {
if (isSticker) {
// Padding is 8px, on both sides
return STICKER_SIZE + 8 * 2;
}
const dimensions = getGridDimensions(attachments);
if (dimensions) {
return dimensions.width;
@ -949,6 +993,7 @@ export class Message extends React.PureComponent<Props, State> {
const { width } = first.image;
if (
!first.isStickerPack &&
isImageAttachment(first.image) &&
width &&
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH
@ -999,11 +1044,13 @@ export class Message extends React.PureComponent<Props, State> {
const {
authorPhoneNumber,
authorColor,
attachments,
direction,
id,
isSticker,
timestamp,
} = this.props;
const { expired, expiring } = this.state;
const { expired, expiring, imageBroken } = this.state;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
@ -1013,6 +1060,10 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
if (isSticker && (imageBroken || !attachments || !attachments.length)) {
return null;
}
const width = this.getWidth();
const isShowingImage = this.isShowingImage();
@ -1029,8 +1080,9 @@ export class Message extends React.PureComponent<Props, State> {
<div
className={classNames(
'module-message__container',
`module-message__container--${direction}`,
direction === 'incoming'
isSticker ? 'module-message__container--with-sticker' : null,
!isSticker ? `module-message__container--${direction}` : null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${authorColor}`
: null
)}

View file

@ -11,6 +11,7 @@ interface Props {
extended?: boolean;
module?: string;
withImageNoCaption?: boolean;
withSticker?: boolean;
direction?: 'incoming' | 'outgoing';
i18n: LocalizerType;
}
@ -48,6 +49,7 @@ export class Timestamp extends React.Component<Props> {
module,
timestamp,
withImageNoCaption,
withSticker,
extended,
} = this.props;
const moduleName = module || 'module-timestamp';
@ -61,7 +63,8 @@ export class Timestamp extends React.Component<Props> {
className={classNames(
moduleName,
direction ? `${moduleName}--${direction}` : null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
withSticker ? `${moduleName}--with-sticker` : null
)}
title={moment(timestamp).format('llll')}
>

View file

@ -0,0 +1,271 @@
#### Default
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
<util.ConversationContext theme={util.theme}>
<div
style={{
height: '500px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<StickerButton
i18n={util.i18n}
receivedPacks={[]}
installedPacks={packs}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')}
onClickAddPack={() => console.log('onClickAddPack')}
recentStickers={[abeSticker, sticker1, sticker2, sticker3]}
/>
</div>
</util.ConversationContext>;
```
#### No Installed Packs
When there are no installed packs the button should call the `onClickAddPack`
callback.
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
<util.ConversationContext theme={util.theme}>
<div
style={{
height: '500px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<StickerButton
i18n={util.i18n}
receivedPacks={[]}
installedPacks={packs}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')}
onClickAddPack={() => console.log('onClickAddPack')}
recentStickers={[abeSticker, sticker1, sticker2, sticker3]}
/>
</div>
</util.ConversationContext>;
```
#### No Advertised Packs and No Installed Packs
When there are no advertised packs and no installed packs the button should not render anything.
```jsx
<util.ConversationContext theme={util.theme}>
<StickerButton
i18n={util.i18n}
receivedPacks={[]}
installedPacks={[]}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')}
onClickAddPack={() => console.log('onClickAddPack')}
recentStickers={[]}
/>
</util.ConversationContext>
```
#### Installed Pack Tooltip
When a pack is installed there should be a tooltip saying as such.
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
title: 'Abe',
cover: abeSticker,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...abeSticker, id })),
},
{
id: 'bar',
cover: sticker1,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'baz',
cover: sticker2,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'qux',
cover: sticker3,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
<util.ConversationContext theme={util.theme}>
<div
style={{
height: '500px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<StickerButton
i18n={util.i18n}
receivedPacks={[]}
installedPacks={packs}
installedPack={packs[0]}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')}
onClickAddPack={() => console.log('onClickAddPack')}
recentStickers={[]}
/>
</div>
</util.ConversationContext>;
```
#### New Installation Splash Tooltip
When the application is updated or freshly installed there should be a tooltip
showing the user the sticker button.
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
title: 'Abe',
cover: abeSticker,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...abeSticker, id })),
},
{
id: 'bar',
cover: sticker1,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'baz',
cover: sticker2,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'qux',
cover: sticker3,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
<util.ConversationContext theme={util.theme}>
<div
style={{
height: '500px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'flex-end',
}}
>
<StickerButton
i18n={util.i18n}
receivedPacks={[]}
installedPacks={packs}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')}
onClickAddPack={() => console.log('onClickAddPack')}
recentStickers={[]}
showIntroduction
clearShowIntroduction={() => console.log('clearShowIntroduction')}
/>
</div>
</util.ConversationContext>;
```

View file

@ -0,0 +1,255 @@
import * as React from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper';
import { createPortal } from 'react-dom';
import { StickerPicker } from './StickerPicker';
import { StickerPackType, StickerType } from '../../state/ducks/stickers';
import { LocalizerType } from '../../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly receivedPacks: ReadonlyArray<StickerPackType>;
readonly installedPacks: ReadonlyArray<StickerPackType>;
readonly installedPack?: StickerPackType | null;
readonly recentStickers: ReadonlyArray<StickerType>;
readonly clearInstalledStickerPack: () => unknown;
readonly onClickAddPack: () => unknown;
readonly onPickSticker: (packId: string, stickerId: number) => unknown;
readonly showIntroduction?: boolean;
readonly clearShowIntroduction: () => unknown;
readonly showPickerHint: boolean;
readonly clearShowPickerHint: () => unknown;
};
export type Props = OwnProps;
export const StickerButton = React.memo(
// tslint:disable-next-line max-func-body-length
({
i18n,
clearInstalledStickerPack,
onClickAddPack,
onPickSticker,
recentStickers,
receivedPacks,
installedPack,
installedPacks,
showIntroduction,
clearShowIntroduction,
showPickerHint,
clearShowPickerHint,
}: Props) => {
const [open, setOpen] = React.useState(false);
const [popperRoot, setPopperRoot] = React.useState<HTMLElement | null>(
null
);
const handleClickButton = React.useCallback(
() => {
// Clear tooltip state
clearInstalledStickerPack();
// Handle button click
if (installedPacks.length === 0) {
onClickAddPack();
} else if (popperRoot) {
setOpen(false);
} else {
setOpen(true);
}
},
[
clearInstalledStickerPack,
onClickAddPack,
installedPacks,
popperRoot,
setOpen,
]
);
const handlePickSticker = React.useCallback(
(packId: string, stickerId: number) => {
setOpen(false);
onPickSticker(packId, stickerId);
},
[setOpen, onPickSticker]
);
const handleClickAddPack = React.useCallback(
() => {
setOpen(false);
if (showPickerHint) {
clearShowPickerHint();
}
onClickAddPack();
},
[onClickAddPack, showPickerHint, clearShowPickerHint]
);
const handleClearIntroduction = React.useCallback(
() => {
clearInstalledStickerPack();
clearShowIntroduction();
},
[clearInstalledStickerPack, clearShowIntroduction]
);
// Create popper root and handle outside clicks
React.useEffect(
() => {
if (open) {
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = ({ target }: MouseEvent) => {
if (!root.contains(target as Node)) {
setOpen(false);
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null);
};
}
return noop;
},
[open, setOpen, setPopperRoot]
);
// Clear the installed pack after one minute
React.useEffect(
() => {
if (installedPack) {
// tslint:disable-next-line:no-string-based-set-timeout
const timerId = setTimeout(clearInstalledStickerPack, 60 * 1000);
return () => {
clearTimeout(timerId);
};
}
return noop;
},
[installedPack, clearInstalledStickerPack]
);
if (installedPacks.length + receivedPacks.length === 0) {
return null;
}
return (
<Manager>
<Reference>
{({ ref }) => (
<button
ref={ref}
onClick={handleClickButton}
className={classNames({
'module-sticker-button__button': true,
'module-sticker-button__button--active': open,
})}
/>
)}
</Reference>
{!open && !showIntroduction && installedPack ? (
<Popper placement="top-end" key={installedPack.id}>
{({ ref, style, placement, arrowProps }) => (
<div
ref={ref}
style={style}
className="module-sticker-button__tooltip"
role="button"
onClick={clearInstalledStickerPack}
>
<img
className="module-sticker-button__tooltip__image"
src={installedPack.cover.url}
alt={installedPack.title}
/>
<span className="module-sticker-button__tooltip__text">
<span className="module-sticker-button__tooltip__text__title">
{installedPack.title}
</span>{' '}
installed
</span>
<div
ref={arrowProps.ref}
style={arrowProps.style}
className={classNames(
'module-sticker-button__tooltip__triangle',
`module-sticker-button__tooltip__triangle--${placement}`
)}
/>
</div>
)}
</Popper>
) : null}
{!open && showIntroduction ? (
<Popper placement="top-end">
{({ ref, style, placement, arrowProps }) => (
<div
ref={ref}
style={style}
className={classNames(
'module-sticker-button__tooltip',
'module-sticker-button__tooltip--introduction'
)}
role="button"
onClick={handleClearIntroduction}
>
<div className="module-sticker-button__tooltip--introduction__image" />
<div className="module-sticker-button__tooltip--introduction__meta">
<div className="module-sticker-button__tooltip--introduction__meta__title">
{i18n('stickers--StickerManager--Introduction--Title')}
</div>
<div className="module-sticker-button__tooltip--introduction__meta__subtitle">
{i18n('stickers--StickerManager--Introduction--Body')}
</div>
</div>
<div className="module-sticker-button__tooltip--introduction__close">
<button
className="module-sticker-button__tooltip--introduction__close__button"
onClick={handleClearIntroduction}
/>
</div>
<div
ref={arrowProps.ref}
style={arrowProps.style}
className={classNames(
'module-sticker-button__tooltip__triangle',
'module-sticker-button__tooltip__triangle--introduction',
`module-sticker-button__tooltip__triangle--${placement}`
)}
/>
</div>
)}
</Popper>
) : null}
{open && popperRoot
? createPortal(
<Popper placement="top-end">
{({ ref, style }) => (
<StickerPicker
ref={ref}
i18n={i18n}
style={style}
packs={installedPacks}
onClickAddPack={handleClickAddPack}
onPickSticker={handlePickSticker}
recentStickers={recentStickers}
showPickerHint={showPickerHint}
/>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
);
}
);

View file

@ -0,0 +1,178 @@
#### Default
```jsx
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
title: 'Foo',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
title: 'Baz',
author: 'Foo McBarrington (Official)',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
title: 'Third',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
const receivedPacks = packs.map(p => ({ ...p, status: 'advertised' }));
const installedPacks = packs.map(p => ({ ...p, status: 'installed' }));
const blessedPacks = packs.map(p => ({
...p,
status: 'advertised',
isBlessed: true,
}));
<util.ConversationContext theme={util.theme}>
<StickerManager
i18n={util.i18n}
installedPacks={installedPacks}
receivedPacks={receivedPacks}
blessedPacks={blessedPacks}
installStickerPack={id => console.log('installStickerPack', id)}
uninstallStickerPack={id => console.log('uninstallStickerPack', id)}
/>
</util.ConversationContext>;
```
#### No Advertised Packs
```jsx
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
title: 'Foo',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
title: 'Baz',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
title: 'Baz',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
const installedPacks = packs.map(p => ({ ...p, status: 'installed' }));
const noPacks = [];
<util.ConversationContext theme={util.theme}>
<StickerManager
i18n={util.i18n}
installedPacks={installedPacks}
receivedPacks={noPacks}
blessedPacks={noPacks}
installStickerPack={id => console.log('installStickerPack', id)}
uninstallStickerPack={id => console.log('uninstallStickerPack', id)}
/>
</util.ConversationContext>;
```
#### No Installed Packs
```jsx
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
title: 'Foo',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
title: 'Baz',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
title: 'Baz',
author: 'Foo McBarrington',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
const receivedPacks = packs.map(p => ({ ...p, status: 'installed' }));
const noPacks = [];
<util.ConversationContext theme={util.theme}>
<StickerManager
i18n={util.i18n}
installedPacks={noPacks}
receivedPacks={receivedPacks}
blessedPacks={noPacks}
installStickerPack={id => console.log('installStickerPack', id)}
/>
</util.ConversationContext>;
```
#### No Packs at All
```jsx
const noPacks = [];
<util.ConversationContext theme={util.theme}>
<div style={{ height: '500px' }}>
<StickerManager
i18n={util.i18n}
installedPacks={noPacks}
receivedPacks={noPacks}
blessedPacks={noPacks}
installStickerPack={id => console.log('installStickerPack', id)}
uninstallStickerPack={id => console.log('uninstallStickerPack', id)}
/>
</div>
</util.ConversationContext>;
```

View file

@ -0,0 +1,107 @@
import * as React from 'react';
import classNames from 'classnames';
import { StickerManagerPackRow } from './StickerManagerPackRow';
import { StickerPreviewModal } from './StickerPreviewModal';
import { LocalizerType } from '../../types/Util';
import { StickerPackType } from '../../state/ducks/stickers';
export type OwnProps = {
readonly installedPacks: ReadonlyArray<StickerPackType>;
readonly receivedPacks: ReadonlyArray<StickerPackType>;
readonly blessedPacks: ReadonlyArray<StickerPackType>;
readonly installStickerPack: (packId: string, packKey: string) => unknown;
readonly uninstallStickerPack: (packId: string, packKey: string) => unknown;
readonly i18n: LocalizerType;
};
export type Props = OwnProps;
export const StickerManager = React.memo(
({
installedPacks,
receivedPacks,
blessedPacks,
installStickerPack,
uninstallStickerPack,
i18n,
}: Props) => {
const [
packToPreview,
setPackToPreview,
] = React.useState<StickerPackType | null>(null);
const clearPackToPreview = React.useCallback(
() => {
setPackToPreview(null);
},
[setPackToPreview]
);
const previewPack = React.useCallback(
(pack: StickerPackType) => {
setPackToPreview(pack);
},
[clearPackToPreview]
);
return (
<>
{packToPreview ? (
<StickerPreviewModal
i18n={i18n}
pack={packToPreview}
onClose={clearPackToPreview}
installStickerPack={installStickerPack}
uninstallStickerPack={uninstallStickerPack}
/>
) : null}
<div className="module-sticker-manager">
{[
{
i18nKey: 'stickers--StickerManager--InstalledPacks',
i18nEmptyKey: 'stickers--StickerManager--InstalledPacks--Empty',
packs: installedPacks,
},
{
i18nKey: 'stickers--StickerManager--BlessedPacks',
i18nEmptyKey: 'stickers--StickerManager--BlessedPacks--Empty',
packs: blessedPacks,
},
{
i18nKey: 'stickers--StickerManager--ReceivedPacks',
i18nEmptyKey: 'stickers--StickerManager--ReceivedPacks--Empty',
packs: receivedPacks,
},
].map(section => (
<React.Fragment key={section.i18nKey}>
<h2
className={classNames(
'module-sticker-manager__text',
'module-sticker-manager__text--heading'
)}
>
{i18n(section.i18nKey)}
</h2>
{section.packs.length > 0 ? (
section.packs.map(pack => (
<StickerManagerPackRow
key={pack.id}
pack={pack}
i18n={i18n}
onClickPreview={previewPack}
installStickerPack={installStickerPack}
uninstallStickerPack={uninstallStickerPack}
/>
))
) : (
<div className="module-sticker-manager__empty">
{i18n(section.i18nEmptyKey)}
</div>
)}
</React.Fragment>
))}
</div>
</>
);
}
);

View file

@ -0,0 +1,129 @@
import * as React from 'react';
import { StickerPackInstallButton } from './StickerPackInstallButton';
import { ConfirmationModal } from '../ConfirmationModal';
import { LocalizerType } from '../../types/Util';
import { StickerPackType } from '../../state/ducks/stickers';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly pack: StickerPackType;
readonly onClickPreview?: (sticker: StickerPackType) => unknown;
readonly installStickerPack?: (packId: string, packKey: string) => unknown;
readonly uninstallStickerPack?: (packId: string, packKey: string) => unknown;
};
export type Props = OwnProps;
export const StickerManagerPackRow = React.memo(
// tslint:disable-next-line max-func-body-length
({
installStickerPack,
uninstallStickerPack,
onClickPreview,
pack,
i18n,
}: Props) => {
const { id, key, isBlessed } = pack;
const [uninstalling, setUninstalling] = React.useState(false);
const clearUninstalling = React.useCallback(
() => {
setUninstalling(false);
},
[setUninstalling]
);
const handleInstall = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (installStickerPack) {
installStickerPack(id, key);
}
},
[installStickerPack, pack]
);
const handleUninstall = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isBlessed && uninstallStickerPack) {
uninstallStickerPack(id, key);
} else {
setUninstalling(true);
}
},
[setUninstalling, id, key, isBlessed]
);
const handleConfirmUninstall = React.useCallback(
() => {
clearUninstalling();
if (uninstallStickerPack) {
uninstallStickerPack(id, key);
}
},
[id, key, clearUninstalling]
);
const handleClickPreview = React.useCallback(
() => {
if (onClickPreview) {
onClickPreview(pack);
}
},
[onClickPreview, pack]
);
return (
<>
{uninstalling ? (
<ConfirmationModal
i18n={i18n}
onClose={clearUninstalling}
negativeText={i18n('stickers--StickerManager--Uninstall')}
onNegative={handleConfirmUninstall}
>
{i18n('stickers--StickerManager--UninstallWarning')}
</ConfirmationModal>
) : null}
<div
role="button"
onClick={handleClickPreview}
className="module-sticker-manager__pack-row"
>
<img
src={pack.cover.url}
alt={pack.title}
className="module-sticker-manager__pack-row__cover"
/>
<div className="module-sticker-manager__pack-row__meta">
<div className="module-sticker-manager__pack-row__meta__title">
{pack.title}
{pack.isBlessed ? (
<span className="module-sticker-manager__pack-row__meta__blessed-icon" />
) : null}
</div>
<div className="module-sticker-manager__pack-row__meta__author">
{pack.author}
</div>
</div>
<div className="module-sticker-manager__pack-row__controls">
{pack.status === 'advertised' ? (
<StickerPackInstallButton
installed={false}
i18n={i18n}
onClick={handleInstall}
/>
) : (
<StickerPackInstallButton
installed={true}
i18n={i18n}
onClick={handleUninstall}
/>
)}
</div>
</div>
</>
);
}
);

View file

@ -0,0 +1,29 @@
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../../types/Util';
export type OwnProps = {
readonly installed: boolean;
readonly i18n: LocalizerType;
readonly blue?: boolean;
};
export type Props = OwnProps & React.HTMLProps<HTMLButtonElement>;
export const StickerPackInstallButton = React.forwardRef<
HTMLButtonElement,
Props
>(({ i18n, installed, blue, ...props }: Props, ref) => (
<button
ref={ref}
className={classNames({
'module-sticker-manager__install-button': true,
'module-sticker-manager__install-button--blue': blue,
})}
{...props}
>
{installed
? i18n('stickers--StickerManager--Uninstall')
: i18n('stickers--StickerManager--Install')}
</button>
));

View file

@ -0,0 +1,321 @@
#### Default
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
{
id: 'qux',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'quux',
cover: sticker3,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'corge',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'grault',
cover: sticker1,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'garply',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'waldo',
cover: sticker3,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
{
id: 'fred',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'plugh',
cover: sticker1,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'xyzzy',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'thud',
cover: abeSticker,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...abeSticker, id })),
},
{
id: 'banana',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'apple',
cover: sticker1,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'strawberry',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'tombrady',
cover: abeSticker,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...abeSticker, id })),
},
];
<util.ConversationContext theme={util.theme}>
<StickerPicker
i18n={util.i18n}
packs={packs}
recentStickers={[
abeSticker,
sticker1,
sticker2,
sticker3,
{ ...sticker2, id: 9999 },
]}
onClickAddPack={() => console.log('onClickAddPack')}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
/>
</util.ConversationContext>;
```
#### No Recently Used Stickers
The sticker picker defaults to the first pack when there are no recent stickers.
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' };
const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' };
const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' };
const packs = [
{
id: 'foo',
cover: sticker1,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker1, id })),
},
{
id: 'bar',
cover: sticker2,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker2, id })),
},
{
id: 'baz',
cover: sticker3,
stickerCount: 101,
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...sticker3, id })),
},
];
<util.ConversationContext theme={util.theme}>
<StickerPicker
i18n={util.i18n}
packs={packs}
recentStickers={[]}
onClickAddPack={() => console.log('onClickAddPack')}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
/>
</util.ConversationContext>;
```
#### Empty
```jsx
<util.ConversationContext theme={util.theme}>
<StickerPicker
i18n={util.i18n}
packs={[]}
recentStickers={[]}
onClickAddPack={() => console.log('onClickAddPack')}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
/>
</util.ConversationContext>
```
#### Pending Download
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const packs = [
{
id: 'tombrady',
status: 'pending',
cover: abeSticker,
stickerCount: 30,
stickers: [abeSticker],
},
];
<util.ConversationContext theme={util.theme}>
<StickerPicker
i18n={util.i18n}
packs={packs}
recentStickers={[]}
onClickAddPack={() => console.log('onClickAddPack')}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
/>
</util.ConversationContext>;
```
#### Picker Hint
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const packs = [
{
id: 'tombrady',
cover: abeSticker,
stickerCount: 100,
stickers: Array(100)
.fill(0)
.map((_el, i) => ({ ...abeSticker, id: i })),
},
];
<util.ConversationContext theme={util.theme}>
<StickerPicker
i18n={util.i18n}
packs={packs}
recentStickers={[]}
onClickAddPack={() => console.log('onClickAddPack')}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
showPickerHint={true}
/>
</util.ConversationContext>;
```
#### Pack With Error
```jsx
const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' };
const packs = [
{
id: 'tombrady',
status: 'error',
cover: abeSticker,
stickerCount: 3,
stickers: [],
},
{
id: 'foo',
status: 'error',
cover: abeSticker,
stickerCount: 3,
stickers: [abeSticker],
},
];
<util.ConversationContext theme={util.theme}>
<StickerPicker
i18n={util.i18n}
packs={packs}
recentStickers={[]}
onClickAddPack={() => console.log('onClickAddPack')}
onPickSticker={(packId, stickerId) =>
console.log('onPickSticker', { packId, stickerId })
}
/>
</util.ConversationContext>;
```

View file

@ -0,0 +1,282 @@
/* tslint:disable:max-func-body-length */
/* tslint:disable:cyclomatic-complexity */
import * as React from 'react';
import classNames from 'classnames';
import { StickerPackType, StickerType } from '../../state/ducks/stickers';
import { LocalizerType } from '../../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly onClickAddPack: () => unknown;
readonly onPickSticker: (packId: string, stickerId: number) => unknown;
readonly packs: ReadonlyArray<StickerPackType>;
readonly recentStickers: ReadonlyArray<StickerType>;
readonly showPickerHint?: boolean;
};
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
function useTabs<T>(tabs: ReadonlyArray<T>, initialTab = tabs[0]) {
const [tab, setTab] = React.useState(initialTab);
const handlers = React.useMemo(
() =>
tabs.map(t => () => {
setTab(t);
}),
tabs
);
return [tab, handlers] as [T, ReadonlyArray<() => void>];
}
const PACKS_PAGE_SIZE = 7;
const PACK_ICON_WIDTH = 32;
const PACK_PAGE_WIDTH = PACKS_PAGE_SIZE * PACK_ICON_WIDTH;
function getPacksPageOffset(page: number, packs: number): number {
if (page === 0) {
return 0;
}
if (isLastPacksPage(page, packs)) {
return (
PACK_PAGE_WIDTH * (Math.floor(packs / PACKS_PAGE_SIZE) - 1) +
(packs % PACKS_PAGE_SIZE - 1) * PACK_ICON_WIDTH
);
}
return page * PACK_ICON_WIDTH * PACKS_PAGE_SIZE;
}
function isLastPacksPage(page: number, packs: number): boolean {
return page === Math.floor(packs / PACKS_PAGE_SIZE);
}
export const StickerPicker = React.memo(
React.forwardRef<HTMLDivElement, Props>(
(
{
i18n,
packs,
recentStickers,
onClickAddPack,
onPickSticker,
showPickerHint,
style,
}: Props,
ref
) => {
const tabIds = React.useMemo(
() => ['recents', ...packs.map(({ id }) => id)],
packs
);
const [currentTab, [recentsHandler, ...packsHandlers]] = useTabs(
tabIds,
// If there are no recent stickers, default to the first sticker pack, unless there are no sticker packs.
tabIds[recentStickers.length > 0 ? 0 : Math.min(1, tabIds.length)]
);
const selectedPack = packs.find(({ id }) => id === currentTab);
const {
stickers = recentStickers,
title: packTitle = 'Recent Stickers',
} =
selectedPack || {};
const [packsPage, setPacksPage] = React.useState(0);
const onClickPrevPackPage = React.useCallback(
() => {
setPacksPage(i => i - 1);
},
[setPacksPage]
);
const onClickNextPackPage = React.useCallback(
() => {
setPacksPage(i => i + 1);
},
[setPacksPage]
);
const isEmpty = stickers.length === 0;
const downloadError =
selectedPack &&
selectedPack.status === 'error' &&
selectedPack.stickerCount !== selectedPack.stickers.length;
const pendingCount =
selectedPack && selectedPack.status === 'pending'
? selectedPack.stickerCount - stickers.length
: 0;
const hasPacks = packs.length > 0;
const isRecents = hasPacks && currentTab === 'recents';
const showPendingText = pendingCount > 0;
const showDownlaodErrorText = downloadError;
const showEmptyText = !downloadError && isEmpty;
const showText =
showPendingText || showDownlaodErrorText || showEmptyText;
const showLongText = showPickerHint;
return (
<div className="module-sticker-picker" ref={ref} style={style}>
<div className="module-sticker-picker__header">
<div className="module-sticker-picker__header__packs">
<div
className="module-sticker-picker__header__packs__slider"
style={{
transform: `translateX(-${getPacksPageOffset(
packsPage,
packs.length
)}px)`,
}}
>
{hasPacks ? (
<button
onClick={recentsHandler}
className={classNames({
'module-sticker-picker__header__button': true,
'module-sticker-picker__header__button--recents': true,
'module-sticker-picker__header__button--selected':
currentTab === 'recents',
})}
/>
) : null}
{packs.map((pack, i) => (
<button
key={pack.id}
onClick={packsHandlers[i]}
className={classNames(
'module-sticker-picker__header__button',
{
'module-sticker-picker__header__button--selected':
currentTab === pack.id,
'module-sticker-picker__header__button--error':
pack.status === 'error',
}
)}
>
<img
className="module-sticker-picker__header__button__image"
src={pack.cover.url}
alt={pack.title}
title={pack.title}
/>
</button>
))}
</div>
{packsPage > 0 ? (
<button
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--prev-page'
)}
onClick={onClickPrevPackPage}
/>
) : null}
{!isLastPacksPage(packsPage, packs.length) ? (
<button
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--next-page'
)}
onClick={onClickNextPackPage}
/>
) : null}
</div>
<button
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--add-pack',
{
'module-sticker-picker__header__button--hint': showPickerHint,
}
)}
onClick={onClickAddPack}
/>
</div>
<div
className={classNames('module-sticker-picker__body', {
'module-sticker-picker__body--empty': isEmpty,
})}
>
{showPickerHint ? (
<div
className={classNames(
'module-sticker-picker__body__text',
'module-sticker-picker__body__text--hint',
{
'module-sticker-picker__body__text--pin': showEmptyText,
}
)}
>
{i18n('stickers--StickerPicker--Hint')}
</div>
) : null}
{!hasPacks ? (
<div className="module-sticker-picker__body__text">
{i18n('stickers--StickerPicker--NoPacks')}
</div>
) : null}
{pendingCount > 0 ? (
<div className="module-sticker-picker__body__text">
{i18n('stickers--StickerPicker--DownloadPending')}
</div>
) : null}
{downloadError ? (
<div
className={classNames(
'module-sticker-picker__body__text',
'module-sticker-picker__body__text--error'
)}
>
{stickers.length > 0
? i18n('stickers--StickerPicker--DownloadError')
: i18n('stickers--StickerPicker--Empty')}
</div>
) : null}
{hasPacks && showEmptyText ? (
<div
className={classNames('module-sticker-picker__body__text', {
'module-sticker-picker__body__text--error': !isRecents,
})}
>
{isRecents
? i18n('stickers--StickerPicker--NoRecents')
: i18n('stickers--StickerPicker--Empty')}
</div>
) : null}
{!isEmpty ? (
<div
className={classNames('module-sticker-picker__body__content', {
'module-sticker-picker__body__content--under-text': showText,
'module-sticker-picker__body__content--under-long-text': showLongText,
})}
>
{stickers.map(({ packId, id, url }) => (
<button
key={`${packId}-${id}`}
className="module-sticker-picker__body__cell"
onClick={() => onPickSticker(packId, id)}
>
<img
className="module-sticker-picker__body__cell__image"
src={url}
alt={packTitle}
/>
</button>
))}
{Array(pendingCount)
.fill(0)
.map((_, i) => (
<div
key={i}
className="module-sticker-picker__body__cell__placeholder"
role="presentation"
/>
))}
</div>
) : null}
</div>
</div>
);
}
)
);

View file

@ -0,0 +1,29 @@
#### Not yet installed
```jsx
const abeSticker = { url: util.squareStickerObjectUrl, packId: 'abe' };
const pack = {
id: 'foo',
cover: abeSticker,
title: 'Foo',
isBlessed: true,
author: 'Foo McBarrington',
status: 'advertised',
stickers: Array(101)
.fill(0)
.map((n, id) => ({ ...abeSticker, id })),
};
<util.ConversationContext theme={util.theme}>
<StickerPreviewModal
onClose={() => console.log('onClose')}
installStickerPack={(...args) => console.log('installStickerPack', ...args)}
uninstallStickerPack={(...args) =>
console.log('uninstallStickerPack', ...args)
}
i18n={util.i18n}
pack={pack}
/>
</util.ConversationContext>;
```

View file

@ -0,0 +1,165 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { StickerPackInstallButton } from './StickerPackInstallButton';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { LocalizerType } from '../../types/Util';
import { StickerPackType } from '../../state/ducks/stickers';
export type OwnProps = {
readonly onClose: () => unknown;
readonly installStickerPack: (packId: string, packKey: string) => unknown;
readonly uninstallStickerPack: (packId: string, packKey: string) => unknown;
readonly pack: StickerPackType;
readonly i18n: LocalizerType;
};
export type Props = OwnProps;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const StickerPreviewModal = React.memo(
// tslint:disable-next-line max-func-body-length
({
onClose,
pack,
i18n,
installStickerPack,
uninstallStickerPack,
}: Props) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [confirmingUninstall, setConfirmingUninstall] = React.useState(false);
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
const isInstalled = pack.status === 'installed';
const handleToggleInstall = React.useCallback(
() => {
if (isInstalled) {
setConfirmingUninstall(true);
} else {
installStickerPack(pack.id, pack.key);
onClose();
}
},
[isInstalled, pack, setConfirmingUninstall, installStickerPack, onClose]
);
const handleUninstall = React.useCallback(
() => {
uninstallStickerPack(pack.id, pack.key);
setConfirmingUninstall(false);
// onClose is called by the confirmation modal
},
[uninstallStickerPack, setConfirmingUninstall, pack]
);
React.useEffect(
() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
onClose();
}
};
document.addEventListener('keyup', handler);
return () => {
document.removeEventListener('keyup', handler);
};
},
[onClose]
);
const handleClickToClose = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
return root
? createPortal(
<div
role="button"
className="module-sticker-manager__preview-modal__overlay"
onClick={handleClickToClose}
>
{confirmingUninstall ? (
<ConfirmationDialog
i18n={i18n}
onClose={onClose}
negativeText={i18n('stickers--StickerManager--Uninstall')}
onNegative={handleUninstall}
>
{i18n('stickers--StickerManager--UninstallWarning')}
</ConfirmationDialog>
) : (
<div className="module-sticker-manager__preview-modal__container">
<header className="module-sticker-manager__preview-modal__container__header">
<h2 className="module-sticker-manager__preview-modal__container__header__text">
{i18n('stickers--StickerPreview--Title')}
</h2>
<button
onClick={onClose}
className="module-sticker-manager__preview-modal__container__header__close-button"
/>
</header>
<div className="module-sticker-manager__preview-modal__container__sticker-grid">
{pack.stickers.map(({ id, url }) => (
<div
key={id}
className="module-sticker-manager__preview-modal__container__sticker-grid__cell"
>
<img
className="module-sticker-manager__preview-modal__container__sticker-grid__cell__image"
src={url}
alt={pack.title}
/>
</div>
))}
</div>
<div className="module-sticker-manager__preview-modal__container__meta-overlay">
<div className="module-sticker-manager__preview-modal__container__meta-overlay__info">
<h3 className="module-sticker-manager__preview-modal__container__meta-overlay__info__title">
{pack.title}
{pack.isBlessed ? (
<span className="module-sticker-manager__preview-modal__container__meta-overlay__info__blessed-icon" />
) : null}
</h3>
<h4 className="module-sticker-manager__preview-modal__container__meta-overlay__info__author">
{pack.author}
</h4>
</div>
<div className="module-sticker-manager__preview-modal__container__meta-overlay__install">
<StickerPackInstallButton
ref={focusRef}
installed={isInstalled}
i18n={i18n}
onClick={handleToggleInstall}
blue={true}
/>
</div>
</div>
</div>
)}
</div>,
root
)
: null;
}
);