mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Authentication from '@app/features/auth/state/Authentication';
|
|
import styles from '@app/features/channel/components/ChannelSourcePreview.module.css';
|
|
import type {Channel} from '@app/features/channel/models/Channel';
|
|
import Channels from '@app/features/channel/state/Channels';
|
|
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
|
import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUtils';
|
|
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
|
import Guilds from '@app/features/guild/state/Guilds';
|
|
import {DIRECT_MESSAGE_DESCRIPTOR, PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import {Link} from '@app/features/platform/components/router/RouterReact';
|
|
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
|
import {MentionBadge} from '@app/features/ui/components/MentionBadge';
|
|
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
|
import Users from '@app/features/user/state/Users';
|
|
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
|
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
|
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
|
|
import {msg, plural} from '@lingui/core/macro';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {CaretRightIcon, HashIcon, NotePencilIcon, UserIcon, UsersIcon} from '@phosphor-icons/react';
|
|
import {clsx} from 'clsx';
|
|
import {observer} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useMemo} from 'react';
|
|
|
|
const CHANNEL_DESCRIPTOR = msg({
|
|
message: 'Channel',
|
|
comment: 'Short label in the channel source preview. Keep it concise.',
|
|
});
|
|
const GROUP_DESCRIPTOR = msg({
|
|
message: 'Group',
|
|
comment: 'Short label in the channel source preview. Keep it concise.',
|
|
});
|
|
const JUMP_TO_DESCRIPTOR = msg({
|
|
message: 'Jump to {displayName}',
|
|
comment:
|
|
'Short label in the channel source preview. Keep it concise. Preserve {displayName}; it is inserted by code.',
|
|
});
|
|
|
|
type SubtextTone = 'muted' | 'primary';
|
|
|
|
interface ChannelAvatarFallbackLabels {
|
|
channel: string;
|
|
directMessage: string;
|
|
group: string;
|
|
personalNotes: string;
|
|
}
|
|
|
|
interface ChannelSourcePreviewProps {
|
|
channel: Channel;
|
|
onClick?: () => void;
|
|
linkTo?: string;
|
|
onContextMenu?: (event: React.MouseEvent<HTMLElement>) => void;
|
|
mentionCount?: number;
|
|
avatarSize?: MediaProxyImageSize;
|
|
variant?: 'default' | 'inline';
|
|
showChannelIcon?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
function getChannelDisplayName(channel: Channel): string {
|
|
if (channel.isPrivate()) {
|
|
return ChannelUtils.getDMDisplayName(channel);
|
|
}
|
|
return channel.name?.trim() || ChannelUtils.getName(channel);
|
|
}
|
|
|
|
function getGroupDmMemberCount(channel: Channel): number {
|
|
const currentUserId = Authentication.currentUserId;
|
|
if (!currentUserId) return channel.recipientIds.length;
|
|
const memberIds = new Set(channel.recipientIds);
|
|
memberIds.add(currentUserId);
|
|
return memberIds.size;
|
|
}
|
|
|
|
function renderAvatarImage(props: {url: string; label: string; size: number}): React.ReactNode {
|
|
const {url, label, size} = props;
|
|
return (
|
|
<div
|
|
className={styles.avatar}
|
|
style={{width: remFromPx(size), height: remFromPx(size), backgroundImage: `url(${url})`}}
|
|
role="img"
|
|
aria-label={label}
|
|
data-flx="channel.channel-source-preview.render-avatar-image.avatar"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderAvatarFallback(props: {
|
|
label: string;
|
|
size: number;
|
|
background?: string;
|
|
content: React.ReactNode;
|
|
}): React.ReactNode {
|
|
const {label, size, background, content} = props;
|
|
return (
|
|
<div
|
|
className={styles.avatar}
|
|
style={{width: remFromPx(size), height: remFromPx(size), backgroundColor: background}}
|
|
role="img"
|
|
aria-label={label}
|
|
data-flx="channel.channel-source-preview.render-avatar-fallback.avatar"
|
|
>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderChannelAvatar(
|
|
channel: Channel,
|
|
size: MediaProxyImageSize,
|
|
fallbackLabels: ChannelAvatarFallbackLabels,
|
|
): React.ReactNode {
|
|
if (channel.guildId) {
|
|
const guild = Guilds.getGuild(channel.guildId);
|
|
if (!guild) {
|
|
return renderAvatarFallback({
|
|
label: fallbackLabels.channel,
|
|
size,
|
|
content: (
|
|
<HashIcon
|
|
className={styles.fallbackIcon}
|
|
weight="bold"
|
|
data-flx="channel.channel-source-preview.render-channel-avatar.fallback-icon"
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
return (
|
|
<GuildIcon
|
|
id={guild.id}
|
|
name={guild.name}
|
|
icon={guild.icon}
|
|
sizePx={size}
|
|
containerProps={{
|
|
role: 'img',
|
|
'aria-label': guild.name,
|
|
'data-flx': 'channel.channel-source-preview.render-channel-avatar.guild-icon',
|
|
}}
|
|
data-flx="channel.channel-source-preview.render-channel-avatar.guild-icon"
|
|
/>
|
|
);
|
|
}
|
|
if (channel.isDM()) {
|
|
const recipientId = channel.getRecipientId();
|
|
const recipient = recipientId ? Users.getUser(recipientId) : null;
|
|
if (recipient) {
|
|
const url = AvatarUtils.getUserAvatarURL({id: recipient.id, avatar: recipient.avatar}, false);
|
|
return renderAvatarImage({url, label: recipient.tag, size});
|
|
}
|
|
return renderAvatarFallback({
|
|
label: fallbackLabels.directMessage,
|
|
size,
|
|
content: (
|
|
<UserIcon
|
|
className={styles.fallbackIcon}
|
|
weight="bold"
|
|
data-flx="channel.channel-source-preview.render-channel-avatar.fallback-icon--2"
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
if (channel.isGroupDM()) {
|
|
const iconUrl = AvatarUtils.getChannelIconURL(
|
|
{id: channel.id, icon: channel.icon},
|
|
MEDIA_PROXY_ICON_SIZE_DEFAULT,
|
|
true,
|
|
);
|
|
if (iconUrl) {
|
|
return renderAvatarImage({url: iconUrl, label: fallbackLabels.group, size});
|
|
}
|
|
const accentColor = getGroupDMAccentColor(channel.id);
|
|
return renderAvatarFallback({
|
|
label: fallbackLabels.group,
|
|
size,
|
|
background: accentColor,
|
|
content: (
|
|
<UsersIcon
|
|
className={styles.fallbackIcon}
|
|
weight="bold"
|
|
data-flx="channel.channel-source-preview.render-channel-avatar.fallback-icon--3"
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
if (channel.isPersonalNotes()) {
|
|
return renderAvatarFallback({
|
|
label: fallbackLabels.personalNotes,
|
|
size,
|
|
content: (
|
|
<NotePencilIcon
|
|
className={styles.fallbackIcon}
|
|
weight="bold"
|
|
data-flx="channel.channel-source-preview.render-channel-avatar.fallback-icon--4"
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
return renderAvatarFallback({
|
|
label: fallbackLabels.channel,
|
|
size,
|
|
content: (
|
|
<HashIcon
|
|
className={styles.fallbackIcon}
|
|
weight="bold"
|
|
data-flx="channel.channel-source-preview.render-channel-avatar.fallback-icon--5"
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
export const ChannelSourcePreview = observer(function ChannelSourcePreview({
|
|
channel,
|
|
onClick,
|
|
linkTo,
|
|
onContextMenu,
|
|
mentionCount,
|
|
avatarSize,
|
|
variant = 'default',
|
|
showChannelIcon = true,
|
|
className,
|
|
}: ChannelSourcePreviewProps) {
|
|
const {i18n} = useLingui();
|
|
const guild = channel.guildId ? (Guilds.getGuild(channel.guildId) ?? null) : null;
|
|
const category = channel.parentId ? (Channels.getChannel(channel.parentId) ?? null) : null;
|
|
const isGroupDm = channel.isGroupDM();
|
|
const isUserDm = channel.isDM();
|
|
const channelDisplayName = getChannelDisplayName(channel);
|
|
const displayName = isUserDm ? `@${channelDisplayName}` : channelDisplayName;
|
|
const resolvedAvatarSize: MediaProxyImageSize = avatarSize ?? (variant === 'inline' ? 24 : 32);
|
|
const avatarFallbackLabels = useMemo(
|
|
() => ({
|
|
channel: i18n._(CHANNEL_DESCRIPTOR),
|
|
directMessage: i18n._(DIRECT_MESSAGE_DESCRIPTOR),
|
|
group: i18n._(GROUP_DESCRIPTOR),
|
|
personalNotes: i18n._(PERSONAL_NOTES_DESCRIPTOR),
|
|
}),
|
|
[i18n.locale],
|
|
);
|
|
const {subtext, subtextTone} = useMemo(() => {
|
|
if (isGroupDm) {
|
|
const memberCount = getGroupDmMemberCount(channel);
|
|
const label = plural(
|
|
{count: memberCount},
|
|
{
|
|
one: '# member',
|
|
other: '# members',
|
|
},
|
|
);
|
|
return {subtext: label, subtextTone: 'primary' as SubtextTone};
|
|
}
|
|
if (guild) {
|
|
if (category?.name) {
|
|
return {
|
|
subtext: (
|
|
<span className={styles.subtextBreadcrumb} data-flx="channel.channel-source-preview.subtext-breadcrumb">
|
|
<span data-flx="channel.channel-source-preview.span">{guild.name}</span>
|
|
<CaretRightIcon
|
|
className={styles.subtextChevron}
|
|
weight="bold"
|
|
data-flx="channel.channel-source-preview.subtext-chevron"
|
|
/>
|
|
<span data-flx="channel.channel-source-preview.span--2">{category.name}</span>
|
|
</span>
|
|
),
|
|
subtextTone: 'muted' as SubtextTone,
|
|
};
|
|
}
|
|
return {subtext: guild.name, subtextTone: 'muted' as SubtextTone};
|
|
}
|
|
return {subtext: null, subtextTone: 'muted' as SubtextTone};
|
|
}, [category?.name, channel, guild, isGroupDm, i18n]);
|
|
const nameContent = (
|
|
<span className={styles.nameText} data-flx="channel.channel-source-preview.name-text">
|
|
{displayName}
|
|
</span>
|
|
);
|
|
const nameAriaLabel = i18n._(JUMP_TO_DESCRIPTOR, {displayName});
|
|
const renderNameAction = () => {
|
|
if (linkTo) {
|
|
return (
|
|
<FocusRing
|
|
offset={-2}
|
|
ringClassName={styles.focusRingTight}
|
|
data-flx="channel.channel-source-preview.render-name-action.focus-ring"
|
|
>
|
|
<Link
|
|
to={linkTo}
|
|
className={styles.nameLink}
|
|
aria-label={nameAriaLabel}
|
|
data-flx="channel.channel-source-preview.render-name-action.name-link"
|
|
>
|
|
{nameContent}
|
|
</Link>
|
|
</FocusRing>
|
|
);
|
|
}
|
|
if (onClick) {
|
|
return (
|
|
<FocusRing
|
|
offset={-2}
|
|
ringClassName={styles.focusRingTight}
|
|
data-flx="channel.channel-source-preview.render-name-action.focus-ring--2"
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.nameButton}
|
|
onClick={onClick}
|
|
aria-label={nameAriaLabel}
|
|
data-flx="channel.channel-source-preview.render-name-action.name-button.click"
|
|
>
|
|
{nameContent}
|
|
</button>
|
|
</FocusRing>
|
|
);
|
|
}
|
|
return nameContent;
|
|
};
|
|
const containerInteractionProps = onContextMenu ? {onContextMenu} : {};
|
|
return (
|
|
<div
|
|
className={clsx(styles.container, variant === 'inline' && styles.inline, className)}
|
|
data-flx="channel.channel-source-preview.container"
|
|
{...containerInteractionProps}
|
|
>
|
|
{renderChannelAvatar(channel, resolvedAvatarSize, avatarFallbackLabels)}
|
|
<div className={styles.textContainer} data-flx="channel.channel-source-preview.text-container">
|
|
<div className={styles.nameRow} data-flx="channel.channel-source-preview.name-row">
|
|
{showChannelIcon && channel.guildId
|
|
? ChannelUtils.getIcon(channel, {className: styles.channelIcon, weight: 'bold'})
|
|
: null}
|
|
{renderNameAction()}
|
|
{mentionCount != null && mentionCount > 0 ? (
|
|
<MentionBadge
|
|
mentionCount={mentionCount}
|
|
size="small"
|
|
data-flx="channel.channel-source-preview.mention-badge"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{subtext ? (
|
|
<div
|
|
className={clsx(styles.subtext, subtextTone === 'primary' ? styles.subtextPrimary : styles.subtextMuted)}
|
|
data-flx="channel.channel-source-preview.subtext"
|
|
>
|
|
{subtext}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|