fix(app): list friends with closed DMs in the forward modal (#2787)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-15 00:39:18 +02:00
1 parent 951e39da3d
commit c5aaf65a10
5 files changed
+255 -88

No files matched your search

@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
ForwardChannelIndex,
type ForwardChannelObservation,
} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
import type {Channel} from '@app/features/channel/models/Channel';
import type {User} from '@app/features/user/models/User';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {setupI18n} from '@lingui/core';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => ({msg: (descriptor: unknown) => descriptor}));
vi.mock('@app/features/channel/components/SlowmodeIndicator', () => ({formatSlowmodeTime: () => ''}));
vi.mock('@app/features/permissions/utils/PermissionUtils', () => ({formatPermissionLabel: () => ''}));
const i18n = setupI18n({locale: 'en', messages: {en: {}}});
function observation(overrides: Partial<ForwardChannelObservation>): ForwardChannelObservation {
return {
canAttachFiles: true,
canEmbedLinks: true,
canSendMessages: true,
categoryName: null,
channel: null,
displayName: '',
guildMessagesDisabled: false,
guildName: null,
key: '',
memberTimedOut: false,
recipient: null,
searchAliases: [],
slowmodeEnabled: false,
slowmodeRemainingMs: 0,
...overrides,
};
}
const general = observation({
channel: {id: 'channel-1', name: 'general', type: ChannelTypes.GUILD_TEXT} as unknown as Channel,
displayName: 'general',
key: 'channel-1',
});
const closedFriendDM = observation({
displayName: 'Ada Lovelace',
key: 'user:user-1',
recipient: {id: 'user-1'} as unknown as User,
searchAliases: ['ada_dev'],
});
function buildIndex(observations: ReadonlyArray<ForwardChannelObservation>): ForwardChannelIndex {
return new ForwardChannelIndex({
excludedChannelId: 'channel-1',
i18n,
mediaSelection: {hasAttachments: true, hasEmbeds: true},
observations,
recentChannelIds: [],
});
}
describe('forward channel index', () => {
it('lists a friend whose DM is closed ahead of the source channel', () => {
const keys = buildIndex([general, closedFriendDM])
.filter('')
.map((option) => option.key);
expect(keys).toEqual(['user:user-1', 'channel-1']);
});
it('finds a friend whose DM is closed by username', () => {
const matches = buildIndex([general, closedFriendDM]).filter('ada_dev');
expect(matches.map((option) => option.key)).toEqual(['user:user-1']);
expect(matches[0]?.channel).toBeNull();
expect(matches[0]?.disableReason).toBeNull();
});
it('selects destinations by key', () => {
const selected = buildIndex([general, closedFriendDM]).select(new Set(['user:user-1', 'channel-1', 'missing']));
expect(selected.map((option) => option.key)).toEqual(['user:user-1', 'channel-1']);
});
it('drops an observation with neither a channel nor a recipient', () => {
const keys = buildIndex([observation({displayName: 'ghost', key: 'user:ghost'})])
.filter('')
.map((option) => option.key);
expect(keys).toEqual([]);
});
});
@@ -7,6 +7,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {Message} from '@app/features/messaging/models/MessagingMessage'; import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils'; import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
import type {User} from '@app/features/user/models/User';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {I18n} from '@lingui/core'; import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
@@ -88,11 +89,13 @@ export interface ForwardChannelObservation {
readonly canEmbedLinks: boolean; readonly canEmbedLinks: boolean;
readonly canSendMessages: boolean; readonly canSendMessages: boolean;
readonly categoryName: string | null; readonly categoryName: string | null;
readonly channel: Channel; readonly channel: Channel | null;
readonly displayName: string; readonly displayName: string;
readonly guildMessagesDisabled: boolean; readonly guildMessagesDisabled: boolean;
readonly guildName: string | null; readonly guildName: string | null;
readonly key: string;
readonly memberTimedOut: boolean; readonly memberTimedOut: boolean;
readonly recipient: User | null;
readonly searchAliases: ReadonlyArray<string>; readonly searchAliases: ReadonlyArray<string>;
readonly slowmodeEnabled: boolean; readonly slowmodeEnabled: boolean;
readonly slowmodeRemainingMs: number; readonly slowmodeRemainingMs: number;
@@ -100,10 +103,12 @@ export interface ForwardChannelObservation {
export interface ForwardChannelOption { export interface ForwardChannelOption {
readonly categoryName: string | null; readonly categoryName: string | null;
readonly channel: Channel; readonly channel: Channel | null;
readonly disableReason: string | null; readonly disableReason: string | null;
readonly displayName: string; readonly displayName: string;
readonly guildName: string | null; readonly guildName: string | null;
readonly key: string;
readonly recipient: User | null;
readonly slowmodeEnabled: boolean; readonly slowmodeEnabled: boolean;
readonly slowmodeRemainingMs: number; readonly slowmodeRemainingMs: number;
} }
@@ -129,13 +134,18 @@ interface BuildForwardChannelIndexRequest {
interface ForwardChannelSelectionDisabledRequest { interface ForwardChannelSelectionDisabledRequest {
readonly maxSelections: number; readonly maxSelections: number;
readonly option: ForwardChannelOption; readonly option: ForwardChannelOption;
readonly selectedChannelIds: ReadonlySet<string>; readonly selectedKeys: ReadonlySet<string>;
}
function isForwardableObservation(observation: ForwardChannelObservation): boolean {
if (observation.channel == null) return observation.recipient != null;
return isForwardableChannelType(observation.channel.type);
} }
export class ForwardChannelIndex { export class ForwardChannelIndex {
private readonly i18n: I18n; private readonly i18n: I18n;
private readonly options: ReadonlyArray<IndexedForwardChannelOption>; private readonly options: ReadonlyArray<IndexedForwardChannelOption>;
private readonly optionsByChannelId: ReadonlyMap<string, IndexedForwardChannelOption>; private readonly optionsByKey: ReadonlyMap<string, IndexedForwardChannelOption>;
constructor({ constructor({
excludedChannelId, excludedChannelId,
@@ -147,11 +157,11 @@ export class ForwardChannelIndex {
this.i18n = i18n; this.i18n = i18n;
const recentRanks = ForwardChannelIndex.buildRecentRanks(recentChannelIds); const recentRanks = ForwardChannelIndex.buildRecentRanks(recentChannelIds);
const options = observations const options = observations
.filter((observation) => isForwardableChannelType(observation.channel.type)) .filter(isForwardableObservation)
.map((observation) => this.buildOption({excludedChannelId, mediaSelection, observation, recentRanks})); .map((observation) => this.buildOption({excludedChannelId, mediaSelection, observation, recentRanks}));
options.sort((left, right) => this.compareOptions(left, right)); options.sort((left, right) => this.compareOptions(left, right));
this.options = Object.freeze(options); this.options = Object.freeze(options);
this.optionsByChannelId = new Map(options.map((option) => [option.channel.id, option])); this.optionsByKey = new Map(options.map((option) => [option.key, option]));
} }
filter(searchQuery: string): ReadonlyArray<ForwardChannelOption> { filter(searchQuery: string): ReadonlyArray<ForwardChannelOption> {
@@ -169,16 +179,16 @@ export class ForwardChannelIndex {
return matches; return matches;
} }
isSelectionDisabled({maxSelections, option, selectedChannelIds}: ForwardChannelSelectionDisabledRequest): boolean { isSelectionDisabled({maxSelections, option, selectedKeys}: ForwardChannelSelectionDisabledRequest): boolean {
if (option.disableReason != null) return true; if (option.disableReason != null) return true;
if (selectedChannelIds.has(option.channel.id)) return false; if (selectedKeys.has(option.key)) return false;
return selectedChannelIds.size >= maxSelections; return selectedKeys.size >= maxSelections;
} }
select(selectedChannelIds: ReadonlySet<string>): ReadonlyArray<ForwardChannelOption> { select(selectedKeys: ReadonlySet<string>): ReadonlyArray<ForwardChannelOption> {
const selected: Array<IndexedForwardChannelOption> = []; const selected: Array<IndexedForwardChannelOption> = [];
for (const channelId of selectedChannelIds) { for (const key of selectedKeys) {
const option = this.optionsByChannelId.get(channelId); const option = this.optionsByKey.get(key);
if (option != null) selected.push(option); if (option != null) selected.push(option);
} }
return selected; return selected;
@@ -213,25 +223,29 @@ export class ForwardChannelIndex {
}): IndexedForwardChannelOption { }): IndexedForwardChannelOption {
const permissionIssue = this.resolvePermissionIssue(observation, mediaSelection); const permissionIssue = this.resolvePermissionIssue(observation, mediaSelection);
const disableReason = this.resolveDisableReason(observation, permissionIssue); const disableReason = this.resolveDisableReason(observation, permissionIssue);
const recentRankValue = recentRanks.get(observation.channel.id); let recentRankValue: number | undefined;
if (observation.channel != null) recentRankValue = recentRanks.get(observation.channel.id);
let recentRank: number | null = null; let recentRank: number | null = null;
if (recentRankValue !== undefined) recentRank = recentRankValue; if (recentRankValue !== undefined) recentRank = recentRankValue;
const channel = observation.channel;
let channelNameSearchValue = ''; let channelNameSearchValue = '';
if (observation.channel.name) channelNameSearchValue = observation.channel.name.toLowerCase(); if (channel?.name) channelNameSearchValue = channel.name.toLowerCase();
let guildNameSearchValue = ''; let guildNameSearchValue = '';
if (observation.guildName != null) guildNameSearchValue = observation.guildName.toLowerCase(); if (observation.guildName != null) guildNameSearchValue = observation.guildName.toLowerCase();
return Object.freeze({ return Object.freeze({
categoryName: observation.categoryName, categoryName: observation.categoryName,
channel: observation.channel, channel,
channelNameSearchValue, channelNameSearchValue,
disableReason, disableReason,
displayName: observation.displayName, displayName: observation.displayName,
displayNameSearchValue: observation.displayName.toLowerCase(), displayNameSearchValue: observation.displayName.toLowerCase(),
guildName: observation.guildName, guildName: observation.guildName,
guildNameSearchValue, guildNameSearchValue,
isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES, isPersonalNotes: channel != null && channel.type === ChannelTypes.DM_PERSONAL_NOTES,
isSource: observation.channel.id === excludedChannelId, isSource: channel != null && channel.id === excludedChannelId,
key: observation.key,
recentRank, recentRank,
recipient: observation.recipient,
searchAliasValues: Object.freeze(observation.searchAliases.map((alias) => alias.toLowerCase())), searchAliasValues: Object.freeze(observation.searchAliases.map((alias) => alias.toLowerCase())),
slowmodeEnabled: observation.slowmodeEnabled, slowmodeEnabled: observation.slowmodeEnabled,
slowmodeRemainingMs: observation.slowmodeRemainingMs, slowmodeRemainingMs: observation.slowmodeRemainingMs,
@@ -8,6 +8,7 @@ import {
} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex'; } from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
import {useForwardChannelObservations} from '@app/features/app/components/dialogs/shared/UseForwardChannelObservations'; import {useForwardChannelObservations} from '@app/features/app/components/dialogs/shared/UseForwardChannelObservations';
import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray'; import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray';
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels'; import Channels from '@app/features/channel/state/Channels';
import type {Message} from '@app/features/messaging/models/MessagingMessage'; import type {Message} from '@app/features/messaging/models/MessagingMessage';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel'; import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
@@ -24,30 +25,27 @@ interface UseForwardChannelSelectionOptions {
export interface ForwardChannelSelectionState { export interface ForwardChannelSelectionState {
readonly filteredChannels: ReadonlyArray<ForwardChannelOption>; readonly filteredChannels: ReadonlyArray<ForwardChannelOption>;
readonly handleToggleChannel: (channelId: string) => void; readonly handleToggleChannel: (key: string) => void;
readonly isChannelSelectionDisabled: (option: ForwardChannelOption) => boolean; readonly isChannelSelectionDisabled: (option: ForwardChannelOption) => boolean;
readonly maxSelections: number; readonly maxSelections: number;
readonly mostRecentlySelectedChannelId: string | null; readonly mostRecentlySelectedChannel: Channel | null;
readonly searchQuery: string; readonly searchQuery: string;
readonly selectedChannelIds: ReadonlySet<string>; readonly selectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
readonly selectedKeys: ReadonlySet<string>;
readonly setSearchQuery: Dispatch<SetStateAction<string>>; readonly setSearchQuery: Dispatch<SetStateAction<string>>;
readonly slowmodeActiveSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>; readonly slowmodeActiveSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
readonly slowmodeEnabledSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>; readonly slowmodeEnabledSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
} }
function toggleForwardChannelSelection( function toggleForwardChannelSelection(key: string, maxSelections: number, previousKeys: Set<string>): Set<string> {
channelId: string, const nextKeys = new Set(previousKeys);
maxSelections: number, if (nextKeys.has(key)) {
previousChannelIds: Set<string>, nextKeys.delete(key);
): Set<string> { return nextKeys;
const nextChannelIds = new Set(previousChannelIds);
if (nextChannelIds.has(channelId)) {
nextChannelIds.delete(channelId);
return nextChannelIds;
} }
if (nextChannelIds.size >= maxSelections) return previousChannelIds; if (nextKeys.size >= maxSelections) return previousKeys;
nextChannelIds.add(channelId); nextKeys.add(key);
return nextChannelIds; return nextKeys;
} }
export function useForwardChannelSelection({ export function useForwardChannelSelection({
@@ -79,24 +77,19 @@ export function useForwardChannelSelection({
[excludedChannelId, i18n, locale, observations, recentChannelIds, resolvedMediaSelection], [excludedChannelId, i18n, locale, observations, recentChannelIds, resolvedMediaSelection],
); );
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedChannelIds, setSelectedChannelIds] = useState<Set<string>>(new Set()); const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const filteredChannels = useMemo(() => channelIndex.filter(searchQuery), [channelIndex, searchQuery]); const filteredChannels = useMemo(() => channelIndex.filter(searchQuery), [channelIndex, searchQuery]);
const handleToggleChannel = useCallback( const handleToggleChannel = useCallback(
(channelId: string) => { (key: string) => {
setSelectedChannelIds((previousChannelIds) => setSelectedKeys((previousKeys) => toggleForwardChannelSelection(key, maxSelections, previousKeys));
toggleForwardChannelSelection(channelId, maxSelections, previousChannelIds),
);
}, },
[maxSelections], [maxSelections],
); );
const isChannelSelectionDisabled = useCallback( const isChannelSelectionDisabled = useCallback(
(option: ForwardChannelOption) => channelIndex.isSelectionDisabled({maxSelections, option, selectedChannelIds}), (option: ForwardChannelOption) => channelIndex.isSelectionDisabled({maxSelections, option, selectedKeys}),
[maxSelections, channelIndex, selectedChannelIds], [maxSelections, channelIndex, selectedKeys],
);
const selectedChannelOptions = useMemo(
() => channelIndex.select(selectedChannelIds),
[channelIndex, selectedChannelIds],
); );
const selectedChannelOptions = useMemo(() => channelIndex.select(selectedKeys), [channelIndex, selectedKeys]);
const slowmodeEnabledSelectedChannelOptions = useMemo( const slowmodeEnabledSelectedChannelOptions = useMemo(
() => selectedChannelOptions.filter((option) => option.slowmodeEnabled), () => selectedChannelOptions.filter((option) => option.slowmodeEnabled),
[selectedChannelOptions], [selectedChannelOptions],
@@ -105,22 +98,23 @@ export function useForwardChannelSelection({
() => selectedChannelOptions.filter((option) => option.slowmodeRemainingMs > 0), () => selectedChannelOptions.filter((option) => option.slowmodeRemainingMs > 0),
[selectedChannelOptions], [selectedChannelOptions],
); );
const mostRecentlySelectedChannelId = useMemo(() => { const mostRecentlySelectedChannel = useMemo(() => {
let mostRecent: string | null = null; let mostRecent: Channel | null = null;
for (const channelId of selectedChannelIds) { for (const option of selectedChannelOptions) {
mostRecent = channelId; mostRecent = option.channel;
} }
return mostRecent; return mostRecent;
}, [selectedChannelIds]); }, [selectedChannelOptions]);
return { return {
filteredChannels, filteredChannels,
handleToggleChannel, handleToggleChannel,
isChannelSelectionDisabled, isChannelSelectionDisabled,
maxSelections, maxSelections,
mostRecentlySelectedChannelId, mostRecentlySelectedChannel,
searchQuery, searchQuery,
selectedChannelIds, selectedChannelOptions,
selectedKeys,
setSearchQuery, setSearchQuery,
slowmodeActiveSelectedChannelOptions, slowmodeActiveSelectedChannelOptions,
slowmodeEnabledSelectedChannelOptions, slowmodeEnabledSelectedChannelOptions,
@@ -2,6 +2,7 @@
import type {ForwardChannelObservation} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex'; import type {ForwardChannelObservation} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray'; import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import type {Channel} from '@app/features/channel/models/Channel'; import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels'; import Channels from '@app/features/channel/state/Channels';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
@@ -12,11 +13,13 @@ import Permission from '@app/features/permissions/state/Permission';
import Relationships from '@app/features/relationship/state/Relationships'; import Relationships from '@app/features/relationship/state/Relationships';
import Slowmode from '@app/features/slowmode/state/Slowmode'; import Slowmode from '@app/features/slowmode/state/Slowmode';
import {useNow} from '@app/features/ui/state/Tick'; import {useNow} from '@app/features/ui/state/Tick';
import type {User} from '@app/features/user/models/User';
import Users from '@app/features/user/state/Users'; import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants'; import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {I18n} from '@lingui/core'; import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {computed, type IComputedValue} from 'mobx'; import {computed, type IComputedValue} from 'mobx';
@@ -40,11 +43,13 @@ interface ForwardChannelObservationBase {
readonly canEmbedLinks: boolean; readonly canEmbedLinks: boolean;
readonly canSendMessages: boolean; readonly canSendMessages: boolean;
readonly categoryName: string | null; readonly categoryName: string | null;
readonly channel: Channel; readonly channel: Channel | null;
readonly displayName: string; readonly displayName: string;
readonly guildMessagesDisabled: boolean; readonly guildMessagesDisabled: boolean;
readonly guildName: string | null; readonly guildName: string | null;
readonly key: string;
readonly memberTimedOut: boolean; readonly memberTimedOut: boolean;
readonly recipient: User | null;
readonly searchAliases: ReadonlyArray<string>; readonly searchAliases: ReadonlyArray<string>;
readonly slowmodeEnabled: boolean; readonly slowmodeEnabled: boolean;
} }
@@ -78,13 +83,28 @@ function resolveChannelDisplayName(channel: Channel, i18n: I18n): string {
return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id}); return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id});
} }
function collectRecipientSearchAliases(channel: Channel): ReadonlyArray<string> { function resolveForwardChannelKey(channel: Channel): string {
if (channel.type === ChannelTypes.DM && channel.recipientIds.length > 0) {
return resolveForwardRecipientKey(channel.recipientIds[0]);
}
return channel.id;
}
function resolveForwardRecipientKey(userId: string): string {
return `user:${userId}`;
}
function collectChannelSearchAliases(channel: Channel): ReadonlyArray<string> {
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) { if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
return EMPTY_SEARCH_ALIASES; return EMPTY_SEARCH_ALIASES;
} }
return collectRecipientSearchAliases(channel.recipientIds);
}
function collectRecipientSearchAliases(recipientIds: ReadonlyArray<string>): ReadonlyArray<string> {
const aliases = new Set<string>(); const aliases = new Set<string>();
const guilds = Guilds.getGuilds(); const guilds = Guilds.getGuilds();
for (const recipientId of channel.recipientIds) { for (const recipientId of recipientIds) {
const recipient = Users.getUser(recipientId); const recipient = Users.getUser(recipientId);
if (!recipient) continue; if (!recipient) continue;
aliases.add(recipient.username); aliases.add(recipient.username);
@@ -128,6 +148,7 @@ function areForwardChannelObservationBasesEqual(
right: ForwardChannelObservationBase, right: ForwardChannelObservationBase,
): boolean { ): boolean {
if (left.channel !== right.channel || left.displayName !== right.displayName) return false; if (left.channel !== right.channel || left.displayName !== right.displayName) return false;
if (left.key !== right.key || left.recipient !== right.recipient) return false;
if (left.guildName !== right.guildName || left.categoryName !== right.categoryName) return false; if (left.guildName !== right.guildName || left.categoryName !== right.categoryName) return false;
if (left.guildMessagesDisabled !== right.guildMessagesDisabled || left.memberTimedOut !== right.memberTimedOut) { if (left.guildMessagesDisabled !== right.guildMessagesDisabled || left.memberTimedOut !== right.memberTimedOut) {
return false; return false;
@@ -195,9 +216,12 @@ class ForwardChannelObservationsOwner {
private buildSelection(): ForwardChannelObservationSelection { private buildSelection(): ForwardChannelObservationSelection {
const bases: Array<ForwardChannelObservationBase> = []; const bases: Array<ForwardChannelObservationBase> = [];
const slowmodeChannels: Array<ForwardSlowmodeChannel> = []; const slowmodeChannels: Array<ForwardSlowmodeChannel> = [];
const keys = new Set<string>();
for (const channel of this.request.channels) { for (const channel of this.request.channels) {
const index = bases.length; const index = bases.length;
const base = this.buildBase(channel); const base = this.buildBase(channel);
if (keys.has(base.key)) continue;
keys.add(base.key);
bases.push(base); bases.push(base);
if (base.slowmodeEnabled) { if (base.slowmodeEnabled) {
slowmodeChannels.push( slowmodeChannels.push(
@@ -205,9 +229,42 @@ class ForwardChannelObservationsOwner {
); );
} }
} }
for (const base of this.buildClosedFriendDMBases()) {
if (keys.has(base.key)) continue;
keys.add(base.key);
bases.push(base);
}
return Object.freeze({bases: Object.freeze(bases), slowmodeChannels: Object.freeze(slowmodeChannels)}); return Object.freeze({bases: Object.freeze(bases), slowmodeChannels: Object.freeze(slowmodeChannels)});
} }
private buildClosedFriendDMBases(): ReadonlyArray<ForwardChannelObservationBase> {
if (RuntimeConfig.directMessagesDisabled) return [];
const bases: Array<ForwardChannelObservationBase> = [];
for (const relationship of Relationships.getRelationships()) {
if (relationship.type !== RelationshipTypes.FRIEND) continue;
const recipient = Users.getUser(relationship.userId);
if (!recipient) continue;
bases.push(
Object.freeze({
canAttachFiles: true,
canEmbedLinks: true,
canSendMessages: true,
categoryName: null,
channel: null,
displayName: NicknameUtils.getNickname(recipient, null),
guildMessagesDisabled: false,
guildName: null,
key: resolveForwardRecipientKey(recipient.id),
memberTimedOut: false,
recipient,
searchAliases: collectRecipientSearchAliases([recipient.id]),
slowmodeEnabled: false,
}),
);
}
return bases;
}
private buildBase(channel: Channel): ForwardChannelObservationBase { private buildBase(channel: Channel): ForwardChannelObservationBase {
const guildId = channel.guildId; const guildId = channel.guildId;
if (!guildId) { if (!guildId) {
@@ -220,8 +277,10 @@ class ForwardChannelObservationsOwner {
displayName: resolveChannelDisplayName(channel, this.request.i18n), displayName: resolveChannelDisplayName(channel, this.request.i18n),
guildMessagesDisabled: false, guildMessagesDisabled: false,
guildName: null, guildName: null,
key: resolveForwardChannelKey(channel),
memberTimedOut: false, memberTimedOut: false,
searchAliases: collectRecipientSearchAliases(channel), recipient: null,
searchAliases: collectChannelSearchAliases(channel),
slowmodeEnabled: false, slowmodeEnabled: false,
}); });
} }
@@ -249,8 +308,10 @@ class ForwardChannelObservationsOwner {
displayName: resolveChannelDisplayName(channel, this.request.i18n), displayName: resolveChannelDisplayName(channel, this.request.i18n),
guildMessagesDisabled, guildMessagesDisabled,
guildName, guildName,
key: resolveForwardChannelKey(channel),
memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId), memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId),
searchAliases: collectRecipientSearchAliases(channel), recipient: null,
searchAliases: collectChannelSearchAliases(channel),
slowmodeEnabled, slowmodeEnabled,
}); });
} }
@@ -281,8 +342,10 @@ function buildForwardChannelObservations(
displayName: base.displayName, displayName: base.displayName,
guildMessagesDisabled: base.guildMessagesDisabled, guildMessagesDisabled: base.guildMessagesDisabled,
guildName: base.guildName, guildName: base.guildName,
key: base.key,
searchAliases: base.searchAliases, searchAliases: base.searchAliases,
memberTimedOut: base.memberTimedOut, memberTimedOut: base.memberTimedOut,
recipient: base.recipient,
slowmodeEnabled: base.slowmodeEnabled, slowmodeEnabled: base.slowmodeEnabled,
slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs, slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs,
}); });
@@ -6,6 +6,7 @@ import {useForwardChannelSelection} from '@app/features/app/components/dialogs/s
import selectorStyles from '@app/features/app/components/dialogs/shared/SelectorModalStyles.module.css'; import selectorStyles from '@app/features/app/components/dialogs/shared/SelectorModalStyles.module.css';
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar'; import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
import {Limits} from '@app/features/app/utils/UserLimits'; 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'; import {MessageCharacterCounter} from '@app/features/channel/components/MessageCharacterCounter';
import type {Channel} from '@app/features/channel/models/Channel'; import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels'; import Channels from '@app/features/channel/state/Channels';
@@ -162,6 +163,21 @@ function resolveForwardReferenceGuildId(channel: Channel | null, message: Messag
return null; return null;
} }
async function openForwardChannels(options: ReadonlyArray<ForwardChannelOption>): Promise<Array<string>> {
const channelIds: Array<string> = [];
for (const option of options) {
if (option.channel != null) {
channelIds.push(option.channel.id);
continue;
}
if (option.recipient == null) {
throw new Error(`Forward destination ${option.key} has no channel or recipient`);
}
channelIds.push(await PrivateChannelCommands.ensureDMChannel(option.recipient.id));
}
return channelIds;
}
function focusForwardComment(handle: LexicalRichInputHandle | null): void { function focusForwardComment(handle: LexicalRichInputHandle | null): void {
if (handle != null) { if (handle != null) {
handle.focus(); handle.focus();
@@ -185,9 +201,10 @@ export const ForwardModal = observer(
filteredChannels, filteredChannels,
handleToggleChannel, handleToggleChannel,
isChannelSelectionDisabled, isChannelSelectionDisabled,
mostRecentlySelectedChannelId, mostRecentlySelectedChannel,
searchQuery, searchQuery,
selectedChannelIds, selectedChannelOptions,
selectedKeys,
setSearchQuery, setSearchQuery,
maxSelections, maxSelections,
slowmodeActiveSelectedChannelOptions, slowmodeActiveSelectedChannelOptions,
@@ -218,13 +235,6 @@ export const ForwardModal = observer(
}); });
}, [i18n]); }, [i18n]);
const sourceForwardChannel = resolveForwardSourceChannel(sourceChannel, message.channelId); const sourceForwardChannel = resolveForwardSourceChannel(sourceChannel, message.channelId);
const composerChannel = useMemo(() => {
if (mostRecentlySelectedChannelId == null) {
return null;
}
const selectedChannel = Channels.getChannel(mostRecentlySelectedChannelId);
return selectedChannel == null ? null : selectedChannel;
}, [mostRecentlySelectedChannelId]);
const handleCommentChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => { const handleCommentChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualOptionalMessage(dropTrailingEmptyBlockquoteLines(wire)); setActualOptionalMessage(dropTrailingEmptyBlockquoteLines(wire));
}, []); }, []);
@@ -266,7 +276,7 @@ export const ForwardModal = observer(
} }
const isCommentCounterVisible = actualOptionalMessage.length > user.maxMessageLength * 0.8; const isCommentCounterVisible = actualOptionalMessage.length > user.maxMessageLength * 0.8;
const handleForward = async (skipNavigation = false) => { const handleForward = async (skipNavigation = false) => {
if (selectedChannelIds.size === 0) return; if (selectedChannelOptions.length === 0) return;
if (isForwarding) return; if (isForwarding) return;
if (isSendBlockedBySlowmode) return; if (isSendBlockedBySlowmode) return;
if (!isCommentComposerDisabled && isCommentOverLimit) { if (!isCommentComposerDisabled && isCommentOverLimit) {
@@ -287,7 +297,7 @@ export const ForwardModal = observer(
attachmentIds = mediaSelection.attachmentIds; attachmentIds = mediaSelection.attachmentIds;
embedIndices = mediaSelection.embedIndices; embedIndices = mediaSelection.embedIndices;
} }
const forwardedChannelIds = Array.from(selectedChannelIds); const forwardedChannelIds = await openForwardChannels(selectedChannelOptions);
const forwarded = await MessageCommands.forward( const forwarded = await MessageCommands.forward(
forwardedChannelIds, forwardedChannelIds,
{ {
@@ -335,21 +345,11 @@ export const ForwardModal = observer(
setIsForwarding(false); setIsForwarding(false);
} }
}; };
const getChannelIcon = (ch: Channel) => { const getChannelIcon = (option: ForwardChannelOption) => {
const iconSize = 32; const iconSize = 32;
if (ch.type === ChannelTypes.DM_PERSONAL_NOTES) { const ch = option.channel;
return ( if (ch == null || ch.type === ChannelTypes.DM) {
<NotePencilIcon const user = ch == null ? option.recipient : Users.getUser(ch.recipientIds[0]);
className={selectorStyles.itemIcon}
weight="fill"
size={remFromPx(iconSize)}
data-flx="messaging.forward-modal.get-channel-icon.note-pencil-icon"
/>
);
}
if (ch.type === ChannelTypes.DM) {
const recipientId = ch.recipientIds[0];
const user = Users.getUser(recipientId);
if (!user) return null; if (!user) return null;
return ( return (
<div className={selectorStyles.avatar} data-flx="messaging.forward-modal.get-channel-icon.div"> <div className={selectorStyles.avatar} data-flx="messaging.forward-modal.get-channel-icon.div">
@@ -361,6 +361,16 @@ export const ForwardModal = observer(
</div> </div>
); );
} }
if (ch.type === ChannelTypes.DM_PERSONAL_NOTES) {
return (
<NotePencilIcon
className={selectorStyles.itemIcon}
weight="fill"
size={remFromPx(iconSize)}
data-flx="messaging.forward-modal.get-channel-icon.note-pencil-icon"
/>
);
}
if (ch.type === ChannelTypes.GROUP_DM) { if (ch.type === ChannelTypes.GROUP_DM) {
return ( return (
<div className={selectorStyles.avatar} data-flx="messaging.forward-modal.get-channel-icon.div--2"> <div className={selectorStyles.avatar} data-flx="messaging.forward-modal.get-channel-icon.div--2">
@@ -428,21 +438,20 @@ export const ForwardModal = observer(
) : ( ) : (
<div className={selectorStyles.itemList} data-flx="messaging.forward-modal.div--4"> <div className={selectorStyles.itemList} data-flx="messaging.forward-modal.div--4">
{filteredChannels.map((option: ForwardChannelOption) => { {filteredChannels.map((option: ForwardChannelOption) => {
const ch = option.channel; const isSelected = selectedKeys.has(option.key);
const isSelected = selectedChannelIds.has(ch.id);
const isDisabledForSelection = isChannelSelectionDisabled(option); const isDisabledForSelection = isChannelSelectionDisabled(option);
const isDisabled = isDisabledForSelection && !isSelected; const isDisabled = isDisabledForSelection && !isSelected;
const {categoryName, disableReason, displayName, guildName} = option; const {categoryName, disableReason, displayName, guildName} = option;
return ( return (
<FocusRing <FocusRing
key={ch.id} key={option.key}
offset={-2} offset={-2}
enabled={!isDisabled} enabled={!isDisabled}
data-flx="messaging.forward-modal.focus-ring" data-flx="messaging.forward-modal.focus-ring"
> >
<button <button
type="button" type="button"
onClick={() => !isDisabled && handleToggleChannel(ch.id)} onClick={() => !isDisabled && handleToggleChannel(option.key)}
disabled={isDisabled} disabled={isDisabled}
aria-pressed={isSelected} aria-pressed={isSelected}
className={clsx( className={clsx(
@@ -453,7 +462,7 @@ export const ForwardModal = observer(
data-flx="messaging.forward-modal.button" data-flx="messaging.forward-modal.button"
> >
<div className={selectorStyles.itemContent} data-flx="messaging.forward-modal.div--5"> <div className={selectorStyles.itemContent} data-flx="messaging.forward-modal.div--5">
{getChannelIcon(ch)} {getChannelIcon(option)}
<div className={selectorStyles.itemInfo} data-flx="messaging.forward-modal.div--6"> <div className={selectorStyles.itemInfo} data-flx="messaging.forward-modal.div--6">
<span className={selectorStyles.itemName} data-flx="messaging.forward-modal.span"> <span className={selectorStyles.itemName} data-flx="messaging.forward-modal.span">
{displayName} {displayName}
@@ -517,7 +526,7 @@ export const ForwardModal = observer(
initialValue="" initialValue=""
initialSegments={EMPTY_FORWARD_COMMENT_SEGMENTS} initialSegments={EMPTY_FORWARD_COMMENT_SEGMENTS}
className={modalStyles.richInput} className={modalStyles.richInput}
channel={composerChannel} channel={mostRecentlySelectedChannel}
disabled={isCommentComposerDisabled} disabled={isCommentComposerDisabled}
markdown={true} markdown={true}
singleLine={false} singleLine={false}
@@ -638,7 +647,7 @@ export const ForwardModal = observer(
<Button <Button
onClick={(event: MouseEvent<HTMLButtonElement>) => handleForward(event.shiftKey)} onClick={(event: MouseEvent<HTMLButtonElement>) => handleForward(event.shiftKey)}
disabled={ disabled={
selectedChannelIds.size === 0 || selectedChannelOptions.length === 0 ||
isForwarding || isForwarding ||
isSendBlockedBySlowmode || isSendBlockedBySlowmode ||
(!isCommentComposerDisabled && isCommentOverLimit) (!isCommentComposerDisabled && isCommentOverLimit)
@@ -646,7 +655,7 @@ export const ForwardModal = observer(
data-flx="messaging.forward-modal.button.forward" data-flx="messaging.forward-modal.button.forward"
> >
{i18n._(SEND_SELECTED_COUNT_DESCRIPTOR, { {i18n._(SEND_SELECTED_COUNT_DESCRIPTOR, {
selectedCount: selectedChannelIds.size, selectedCount: selectedKeys.size,
selectionLimit: maxSelections, selectionLimit: maxSelections,
})} })}
</Button> </Button>