feat(app): show where forward destinations come from (#2950)

This commit is contained in:
Hampus
2026-09-25 13:12:17 +02:00
committed by GitHub
parent 00bf74cef5
commit f4f39e6a89
4 changed files with 184 additions and 66 deletions
@@ -418,6 +418,16 @@ function formatGuildChannelDetail(guild: Guild | undefined, channel: Channel): s
return detail === '' ? null : detail;
}
function formatGroupDMDetail(channel: Channel): string | null {
if (channel.type !== ChannelTypes.GROUP_DM || (channel.name?.trim() ?? '') === '') return null;
const names: Array<string> = [];
for (const recipientId of channel.recipientIds) {
const recipient = Users.getUser(recipientId);
if (recipient != null) names.push(NicknameUtils.getNickname(recipient, null, channel.id));
}
return names.length === 0 ? null : names.join(', ');
}
function resolveGuildChannelDisableReason(
channel: Channel,
guild: Guild | undefined,
@@ -469,7 +479,7 @@ function resolveForwardDestinationOption(
return Object.freeze({
channel,
destination,
detail: null,
detail: formatGroupDMDetail(channel),
disableReason: resolveAgeRestrictedDisableReason(channel, mediaNeeds, i18n),
displayName: ChannelUtils.getDMDisplayName(channel),
key,
@@ -0,0 +1,52 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.frame {
--forward-icon-size: 2rem;
--forward-badge-size: 1.125rem;
--forward-badge-offset: -0.1875rem;
--forward-badge-gap: 0.125rem;
--forward-badge-center: calc(var(--forward-icon-size) - var(--forward-badge-size) / 2 - var(--forward-badge-offset));
--forward-badge-cutout: calc(var(--forward-badge-size) / 2 + var(--forward-badge-gap));
position: relative;
display: flex;
flex-shrink: 0;
width: var(--forward-icon-size);
height: var(--forward-icon-size);
}
.cutout {
display: flex;
width: 100%;
height: 100%;
mask-image: radial-gradient(
circle at var(--forward-badge-center) var(--forward-badge-center),
transparent var(--forward-badge-cutout),
#000 calc(var(--forward-badge-cutout) + 0.03125rem)
);
}
.badge {
position: absolute;
right: var(--forward-badge-offset);
bottom: var(--forward-badge-offset);
display: flex;
align-items: center;
justify-content: center;
width: var(--forward-badge-size);
height: var(--forward-badge-size);
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary-muted);
}
.badgeIcon {
width: 0.75rem;
height: 0.75rem;
}
.fallbackIcon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ForwardDestinationOption} from '@app/features/app/components/dialogs/shared/UseForwardDestinations';
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import Guilds from '@app/features/guild/state/Guilds';
import styles from '@app/features/messaging/components/modals/ForwardDestinationIcon.module.css';
import {Avatar} from '@app/features/ui/components/Avatar';
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import Users from '@app/features/user/state/Users';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {observer} from 'mobx-react-lite';
import type {ReactNode} from 'react';
const DESTINATION_ICON_SIZE = 32;
function BadgedIcon({badge, children}: {badge: ReactNode; children: ReactNode}) {
return (
<div className={styles.frame} data-flx="messaging.forward-destination-icon.frame">
<div className={styles.cutout} data-flx="messaging.forward-destination-icon.cutout">
{children}
</div>
<div className={styles.badge} data-flx="messaging.forward-destination-icon.badge">
{badge}
</div>
</div>
);
}
function renderChannelBadge(channel: Channel) {
return ChannelUtils.getIcon(channel, {className: styles.badgeIcon, weight: 'bold'});
}
function renderFallbackIcon(channel: Channel) {
return ChannelUtils.getIcon(channel, {className: styles.fallbackIcon, weight: 'bold'});
}
function renderGuildChannelIcon(channel: Channel) {
const guild = channel.guildId == null ? undefined : Guilds.getGuild(channel.guildId);
if (guild == null) {
return renderFallbackIcon(channel);
}
return (
<BadgedIcon
badge={renderChannelBadge(channel)}
data-flx="messaging.forward-destination-icon.render-guild-channel-icon.badged-icon"
>
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
sizePx={DESTINATION_ICON_SIZE}
data-flx="messaging.forward-destination-icon.guild-icon"
/>
</BadgedIcon>
);
}
function renderPersonalNotesIcon(channel: Channel) {
const currentUser = Users.currentUser;
if (currentUser == null) {
return renderFallbackIcon(channel);
}
return (
<BadgedIcon
badge={renderChannelBadge(channel)}
data-flx="messaging.forward-destination-icon.render-personal-notes-icon.badged-icon"
>
<Avatar
user={currentUser}
size={DESTINATION_ICON_SIZE}
status={null}
data-flx="messaging.forward-destination-icon.avatar"
/>
</BadgedIcon>
);
}
export const ForwardDestinationIcon = observer(function ForwardDestinationIcon({
option,
}: {
option: ForwardDestinationOption;
}) {
if (option.user != null) {
return (
<div className={styles.frame} data-flx="messaging.forward-destination-icon.frame--4">
<StatusAwareAvatar
user={option.user}
size={DESTINATION_ICON_SIZE}
data-flx="messaging.forward-destination-icon.status-aware-avatar"
/>
</div>
);
}
const channel = option.channel;
if (channel == null) return null;
switch (channel.type) {
case ChannelTypes.GROUP_DM:
return (
<div className={styles.frame} data-flx="messaging.forward-destination-icon.frame--5">
<GroupDMAvatar
channel={channel}
size={DESTINATION_ICON_SIZE}
data-flx="messaging.forward-destination-icon.group-dm-avatar"
/>
</div>
);
case ChannelTypes.DM_PERSONAL_NOTES:
return renderPersonalNotesIcon(channel);
default:
return renderGuildChannelIcon(channel);
}
});
@@ -8,7 +8,6 @@ import {
type ForwardDestinationOption,
useForwardDestinations,
} from '@app/features/app/components/dialogs/shared/UseForwardDestinations';
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
import {Limits} from '@app/features/app/utils/UserLimits';
import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands';
import {MessageCharacterCounter} from '@app/features/channel/components/MessageCharacterCounter';
@@ -22,6 +21,7 @@ import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexic
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {MessageForwardFailedModal} from '@app/features/messaging/components/alerts/MessageForwardFailedModal';
import {showMessagingErrorModal} from '@app/features/messaging/components/alerts/MessagingErrorModalUtils';
import {ForwardDestinationIcon} from '@app/features/messaging/components/modals/ForwardDestinationIcon';
import {ForwardMessagePreview} from '@app/features/messaging/components/modals/ForwardMessagePreview';
import modalStyles from '@app/features/messaging/components/modals/ForwardModal.module.css';
import {shouldNavigateAfterForward} from '@app/features/messaging/components/modals/ForwardModalUtils';
@@ -31,7 +31,6 @@ import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegment
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button} from '@app/features/ui/button/Button';
import {Checkbox} from '@app/features/ui/checkbox/Checkbox';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
@@ -39,17 +38,15 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Scroller} from '@app/features/ui/components/Scroller';
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Popout} from '@app/features/ui/popover/PopoverPopout';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import type {User} from '@app/features/user/models/User';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {HashIcon, MagnifyingGlassIcon, NotePencilIcon, SmileyIcon, SpeakerHighIcon} from '@phosphor-icons/react';
import {MagnifyingGlassIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {type MouseEvent, useCallback, useEffect, useId, useRef, useState} from 'react';
@@ -111,8 +108,6 @@ const SEND_SELECTED_COUNT_DESCRIPTOR = msg({
});
const logger = new Logger('ForwardModal');
const DESTINATION_ICON_SIZE = 32;
interface ForwardModalProps {
message: Message;
mediaSelection?: MessageCommands.ForwardMediaSelection;
@@ -205,63 +200,6 @@ function insertForwardEmoji(handle: LexicalRichInputHandle | null, emoji: FlatEm
return handle.insertEmoji(emoji);
}
function renderForwardDestinationIcon(option: ForwardDestinationOption) {
if (option.user != null) {
return (
<div className={selectorStyles.avatar} data-flx="messaging.forward-modal.get-channel-icon.div">
<StatusAwareAvatar
user={option.user}
size={DESTINATION_ICON_SIZE}
data-flx="messaging.forward-modal.get-channel-icon.status-aware-avatar"
/>
</div>
);
}
const channel = option.channel;
if (channel == null) {
return null;
}
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return (
<NotePencilIcon
className={selectorStyles.itemIcon}
weight="fill"
size={remFromPx(DESTINATION_ICON_SIZE)}
data-flx="messaging.forward-modal.get-channel-icon.note-pencil-icon"
/>
);
}
if (channel.type === ChannelTypes.GROUP_DM) {
return (
<div className={selectorStyles.avatar} data-flx="messaging.forward-modal.get-channel-icon.div--2">
<GroupDMAvatar
channel={channel}
size={DESTINATION_ICON_SIZE}
data-flx="messaging.forward-modal.get-channel-icon.group-dm-avatar"
/>
</div>
);
}
if (channel.type === ChannelTypes.GUILD_VOICE) {
return (
<SpeakerHighIcon
className={selectorStyles.itemIcon}
weight="fill"
size={remFromPx(DESTINATION_ICON_SIZE)}
data-flx="messaging.forward-modal.get-channel-icon.speaker-high-icon"
/>
);
}
return (
<HashIcon
className={selectorStyles.itemIcon}
weight="bold"
size={remFromPx(DESTINATION_ICON_SIZE)}
data-flx="messaging.forward-modal.get-channel-icon.hash-icon"
/>
);
}
const EMPTY_FORWARD_COMMENT_SEGMENTS: ReadonlyArray<MentionSegment> = Object.freeze([]);
export const ForwardModal = observer(
@@ -470,7 +408,10 @@ export const ForwardModal = observer(
data-flx="messaging.forward-modal.button"
>
<div className={selectorStyles.itemContent} data-flx="messaging.forward-modal.div--5">
{renderForwardDestinationIcon(option)}
<ForwardDestinationIcon
option={option}
data-flx="messaging.forward-modal.forward-destination-icon"
/>
<div className={selectorStyles.itemInfo} data-flx="messaging.forward-modal.div--6">
<span className={selectorStyles.itemName} data-flx="messaging.forward-modal.span">
{option.displayName}