Show mentioned badges & enable scrolling to mentions in conversations

This commit is contained in:
trevor-signal 2023-05-23 17:59:07 -04:00 committed by GitHub
parent caaeda8abe
commit d012779e87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 694 additions and 184 deletions

View file

@ -369,6 +369,7 @@ export function ConversationList({
'typingContactId',
'unblurredAvatarPath',
'unreadCount',
'unreadMentionsCount',
'uuid',
]);
const { badges, title, unreadCount, lastMessage } = itemProps;

View file

@ -7,15 +7,17 @@ import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import type { Props } from './ScrollDownButton';
import { ScrollDownButton } from './ScrollDownButton';
import type { ScrollDownButtonPropsType } from './ScrollDownButton';
import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
const createProps = (
overrideProps: Partial<ScrollDownButtonPropsType> = {}
): ScrollDownButtonPropsType => ({
variant: ScrollDownButtonVariant.UNREAD_MESSAGES,
i18n,
scrollDown: action('scrollDown'),
conversationId: 'fake-conversation-id',
onClick: action('scrollDown'),
...overrideProps,
});
@ -23,7 +25,7 @@ export default {
title: 'Components/Conversation/ScrollDownButton',
component: ScrollDownButton,
argTypes: {
unreadCount: {
count: {
control: { type: 'radio' },
options: {
None: undefined,
@ -36,10 +38,22 @@ export default {
} as Meta;
// eslint-disable-next-line react/function-component-definition
const Template: Story<Props> = args => <ScrollDownButton {...args} />;
const Template: Story<ScrollDownButtonPropsType> = args => (
<ScrollDownButton {...args} />
);
export const Default = Template.bind({});
Default.args = createProps({});
Default.story = {
name: 'Default',
export const UnreadMessages = Template.bind({});
UnreadMessages.args = createProps({
variant: ScrollDownButtonVariant.UNREAD_MESSAGES,
});
UnreadMessages.story = {
name: 'UnreadMessages',
};
export const UnreadMentions = Template.bind({});
UnreadMentions.args = createProps({
variant: ScrollDownButtonVariant.UNREAD_MENTIONS,
});
UnreadMentions.story = {
name: 'UnreadMentions',
};

View file

@ -1,53 +1,69 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React from 'react';
import type { LocalizerType } from '../../types/Util';
import { getClassNamesFor } from '../../util/getClassNamesFor';
export type Props = {
unreadCount?: number;
conversationId: string;
scrollDown: (conversationId: string) => void;
export enum ScrollDownButtonVariant {
UNREAD_MESSAGES = 'unread-messages',
UNREAD_MENTIONS = 'unread-mentions',
}
export type ScrollDownButtonPropsType = {
variant: ScrollDownButtonVariant;
count?: number;
onClick: VoidFunction;
i18n: LocalizerType;
};
export function ScrollDownButton({
conversationId,
unreadCount,
variant,
count,
onClick,
i18n,
scrollDown,
}: Props): JSX.Element {
const altText = unreadCount
? i18n('icu:messagesBelow')
: i18n('icu:scrollDown');
}: ScrollDownButtonPropsType): JSX.Element {
const getClassName = getClassNamesFor('ScrollDownButton');
let badgeText: string | undefined;
if (unreadCount) {
if (unreadCount < 100) {
badgeText = unreadCount.toString();
if (count) {
if (count < 100) {
badgeText = count.toString();
} else {
badgeText = '99+';
}
}
let altText: string;
switch (variant) {
case ScrollDownButtonVariant.UNREAD_MESSAGES:
altText = count ? i18n('icu:messagesBelow') : i18n('icu:scrollDown');
break;
case ScrollDownButtonVariant.UNREAD_MENTIONS:
altText = i18n('icu:mentionsBelow');
break;
default:
throw new Error(`Unexpected variant: ${variant}`);
}
return (
<div className="ScrollDownButton">
<button
type="button"
className="ScrollDownButton__button"
onClick={() => {
scrollDown(conversationId);
}}
title={altText}
>
{badgeText ? (
<div className="ScrollDownButton__button__badge">{badgeText}</div>
) : null}
<div className="ScrollDownButton__button__icon" />
</button>
</div>
<button
type="button"
className={classNames(getClassName(''), getClassName(`__${variant}`))}
onClick={onClick}
title={altText}
>
{badgeText ? (
<div className={getClassName('__badge')}>{badgeText}</div>
) : null}
<div
className={classNames(
getClassName('__icon'),
getClassName(`__icon--${variant}`)
)}
/>
</button>
);
}

View file

@ -277,6 +277,7 @@ const actions = () => ({
markMessageRead: action('markMessageRead'),
toggleSelectMessage: action('toggleSelectMessage'),
targetMessage: action('targetMessage'),
scrollToOldestUnreadMention: action('scrollToOldestUnreadMention'),
clearTargetedMessage: action('clearTargetedMessage'),
updateSharedGroups: action('updateSharedGroups'),

View file

@ -8,7 +8,7 @@ import React from 'react';
import Measure from 'react-measure';
import type { ReadonlyDeep } from 'type-fest';
import { ScrollDownButton } from './ScrollDownButton';
import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
@ -100,6 +100,7 @@ type PropsHousekeepingType = {
isIncomingMessageRequest: boolean;
isSomeoneTyping: boolean;
unreadCount?: number;
unreadMentionsCount?: number;
targetedMessageId?: string;
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
@ -168,6 +169,7 @@ export type PropsActionsType = {
safeConversationId: string;
}>
) => void;
scrollToOldestUnreadMention: (conversationId: string) => unknown;
};
export type PropsType = PropsDataType &
@ -776,10 +778,12 @@ export class Timeline extends React.Component<
renderTypingBubble,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
scrollToOldestUnreadMention,
shouldShowMiniPlayer,
theme,
totalUnseen,
unreadCount,
unreadMentionsCount,
} = this.props;
const {
hasRecentlyScrolled,
@ -815,7 +819,7 @@ export class Timeline extends React.Component<
areAnyMessagesUnread &&
areAnyMessagesBelowCurrentPosition
);
const shouldShowScrollDownButton = Boolean(
const shouldShowScrollDownButtons = Boolean(
areThereAnyMessages &&
(areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition)
);
@ -1127,14 +1131,24 @@ export class Timeline extends React.Component<
/>
</div>
</main>
{shouldShowScrollDownButtons ? (
<div className="module-timeline__scrolldown-buttons">
{unreadMentionsCount ? (
<ScrollDownButton
variant={ScrollDownButtonVariant.UNREAD_MENTIONS}
count={unreadMentionsCount}
onClick={() => scrollToOldestUnreadMention(id)}
i18n={i18n}
/>
) : null}
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
unreadCount={areUnreadBelowCurrentPosition ? unreadCount : 0}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
<ScrollDownButton
variant={ScrollDownButtonVariant.UNREAD_MESSAGES}
count={areUnreadBelowCurrentPosition ? unreadCount : 0}
onClick={this.onClickScrollDownButton}
i18n={i18n}
/>
</div>
) : null}
</div>
)}

View file

@ -52,6 +52,7 @@ type PropsType = {
onClick?: () => void;
shouldShowSpinner?: boolean;
unreadCount?: number;
unreadMentionsCount?: number;
avatarSize?: AvatarSize;
testId?: string;
} & Pick<
@ -107,6 +108,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
title,
unblurredAvatarPath,
unreadCount,
unreadMentionsCount,
uuid,
} = props;
@ -166,6 +168,25 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
);
}
const unreadIndicators = (() => {
if (!isUnread) {
return null;
}
return (
<div className={`${CONTENT_CLASS_NAME}__unread-indicators`}>
{unreadMentionsCount ? (
<UnreadIndicator variant={UnreadIndicatorVariant.UNREAD_MENTIONS} />
) : null}
{unreadCount ? (
<UnreadIndicator
variant={UnreadIndicatorVariant.UNREAD_MESSAGES}
count={unreadCount}
/>
) : null}
</div>
);
})();
const contents = (
<>
<div className={AVATAR_CONTAINER_CLASS_NAME}>
@ -189,7 +210,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
? { badge: props.badge, theme: props.theme }
: { badge: undefined })}
/>
<UnreadIndicator count={unreadCount} isUnread={isUnread} />
{unreadIndicators}
</div>
<div
className={classNames(
@ -216,7 +237,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
</div>
)}
{messageStatusIcon}
<UnreadIndicator count={unreadCount} isUnread={isUnread} />
{unreadIndicators}
</div>
) : null}
</div>
@ -315,17 +336,46 @@ function Timestamp({
);
}
function UnreadIndicator({
count = 0,
isUnread,
}: Readonly<{ count?: number; isUnread: boolean }>) {
if (!isUnread) {
return null;
enum UnreadIndicatorVariant {
UNREAD_MESSAGES = 'unread-messages',
UNREAD_MENTIONS = 'unread-mentions',
}
type UnreadIndicatorPropsType =
| {
variant: UnreadIndicatorVariant.UNREAD_MESSAGES;
count: number;
}
| { variant: UnreadIndicatorVariant.UNREAD_MENTIONS };
function UnreadIndicator(props: UnreadIndicatorPropsType) {
let content: React.ReactNode;
switch (props.variant) {
case UnreadIndicatorVariant.UNREAD_MESSAGES:
content = props.count > 0 && props.count;
break;
case UnreadIndicatorVariant.UNREAD_MENTIONS:
content = (
<div
className={classNames(
`${BASE_CLASS_NAME}__unread-indicator--${props.variant}__icon`
)}
/>
);
break;
default:
throw new Error('Unexpected variant');
}
return (
<div className={classNames(`${BASE_CLASS_NAME}__unread-indicator`)}>
{Boolean(count) && count}
<div
className={classNames(
`${BASE_CLASS_NAME}__unread-indicator`,
`${BASE_CLASS_NAME}__unread-indicator--${props.variant}`
)}
>
{content}
</div>
);
}

View file

@ -63,6 +63,7 @@ export type PropsData = Pick<
| 'typingContactId'
| 'unblurredAvatarPath'
| 'unreadCount'
| 'unreadMentionsCount'
| 'uuid'
> & {
badge?: BadgeType;
@ -106,6 +107,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
typingContactId,
unblurredAvatarPath,
unreadCount,
unreadMentionsCount,
uuid,
}) {
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
@ -217,6 +219,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
theme={theme}
title={title}
unreadCount={unreadCount}
unreadMentionsCount={unreadMentionsCount}
unblurredAvatarPath={unblurredAvatarPath}
uuid={uuid}
/>