feat: add announcement channels, publishing and following (#3090)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-01 16:57:21 +02:00
1 parent 376afd2ad6
commit 7e4d5137f8
381 files changed
+45650 -14076

No files matched your search

@@ -8,6 +8,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
import Slowmode from '@app/features/slowmode/state/Slowmode';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
import type {ChannelFollowerStatsResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
import type {Channel, ChannelSlowmodeStateResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
@@ -31,6 +32,7 @@ type ChannelUpdateParams = Partial<
Pick<
Channel,
| 'name'
| 'type'
| 'topic'
| 'url'
| 'nsfw'
@@ -155,6 +157,11 @@ export async function update(channelId: string, params: ChannelUpdateParams): Pr
}
}
export async function fetchFollowerStats(channelId: string): Promise<ChannelFollowerStatsResponse> {
const response = await http.get<ChannelFollowerStatsResponse>(Endpoints.CHANNEL_FOLLOWER_STATS(channelId));
return response.body;
}
export async function updateGroupDMNickname(
channelId: string,
userId: string,
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
const logger = new Logger('ChannelFollowCommands');
export async function followChannel(
sourceChannelId: string,
targetChannelId: string,
): Promise<FollowedChannelResponse> {
try {
const response = await http.post<FollowedChannelResponse>(Endpoints.CHANNEL_FOLLOWERS(sourceChannelId), {
body: {webhook_channel_id: targetChannelId},
});
return response.body;
} catch (error) {
logger.error(`Failed to follow channel ${sourceChannelId} into ${targetChannelId}:`, error);
throw error;
}
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {CrosspostSourceResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas';
const logger = new Logger('CrosspostSourceCommands');
export async function fetchCrosspostSource(channelId: string, messageId: string): Promise<CrosspostSourceResponse> {
try {
const response = await http.get<CrosspostSourceResponse>(
Endpoints.CHANNEL_MESSAGE_CROSSPOST_SOURCE(channelId, messageId),
);
return response.body;
} catch (error) {
logger.error(`Failed to fetch crosspost source for message ${messageId} in ${channelId}:`, error);
throw error;
}
}
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {CrosspostCommunityPopout} from '@app/features/channel/components/CrosspostCommunityPopout';
import joinStyles from '@app/features/channel/components/GuildJoinMessage.module.css';
import {SystemMessage} from '@app/features/channel/components/SystemMessage';
import {SystemMessageUsername} from '@app/features/channel/components/SystemMessageUsername';
import {useSystemMessageData} from '@app/features/messaging/hooks/useSystemMessageData';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import styles from '@app/features/theme/styles/Message.module.css';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Trans} from '@lingui/react/macro';
import {ArrowRightIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
interface ChannelFollowAddMessageProps {
message: Message;
}
export const ChannelFollowAddMessage = observer(({message}: ChannelFollowAddMessageProps) => {
const {author, guild} = useSystemMessageData(message);
const sourceComponent = (
<CrosspostCommunityPopout
key="source"
message={message}
data-flx="channel.channel-follow-add-message.crosspost-community-popout"
>
<FocusRing data-flx="channel.channel-follow-add-message.focus-ring">
<button
type="button"
className={styles.systemMessageLink}
data-flx="channel.channel-follow-add-message.system-message-link"
>
{message.content}
</button>
</FocusRing>
</CrosspostCommunityPopout>
);
const messageContent = (
<Trans comment="System message shown in a channel after someone makes it follow an announcement channel. The source is the follower webhook name, usually the source community and channel, for example 'Fluxer News #announcements'.">
<SystemMessageUsername
key={author.id}
author={author}
guild={guild}
message={message}
data-flx="channel.channel-follow-add-message.system-message-username"
/>{' '}
followed {sourceComponent} into this channel. Messages published there will appear here.
</Trans>
);
return (
<SystemMessage
icon={ArrowRightIcon}
iconWeight="bold"
iconClassname={joinStyles.icon}
message={message}
messageContent={messageContent}
data-flx="channel.channel-follow-add-message.system-message"
/>
);
});
@@ -386,6 +386,11 @@
overflow: hidden;
}
.followButton {
margin-left: var(--channel-header-name-gap);
flex-shrink: 0;
}
.topicDivider {
margin: 0 var(--channel-header-topic-divider-gap);
flex-shrink: 0;
@@ -39,6 +39,7 @@ import {
} from '@app/features/channel/components/channel_header/shared';
import {useChannelHeaderData} from '@app/features/channel/components/channel_header/useChannelHeaderData';
import {CallButtons} from '@app/features/channel/components/channel_header_components/CallButtons';
import {ChannelFollowButton} from '@app/features/channel/components/channel_header_components/ChannelFollowButton';
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
import {ChannelNotificationSettingsButton} from '@app/features/channel/components/channel_header_components/ChannelNotificationSettingsButton';
import {ChannelPinsButton} from '@app/features/channel/components/channel_header_components/ChannelPinsButton';
@@ -860,6 +861,13 @@ export const ChannelHeader = observer(
{channelName}
</span>
</Tooltip>
{!isMobile && (
<ChannelFollowButton
channel={channel}
className={styles.followButton}
data-flx="channel.channel-header.channel-follow-button"
/>
)}
{channel.topic && (
<>
<span className={styles.topicDivider} data-flx="channel.channel-header.topic-divider">
@@ -569,6 +569,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
canDeleteAttachment: true,
canPinMessage: true,
canForwardMessage: true,
canCrosspostMessage: false,
canSuppressEmbeds: true,
shouldRenderSuppressEmbeds: false,
}
@@ -716,7 +717,11 @@ export const Message: React.FC<MessageProps> = observer((props) => {
data-flx-author-self={message.author.id === Users.currentUserId ? 'true' : undefined}
data-flx-author-bot={message.author.bot ? 'true' : undefined}
data-flx-author-webhook={message.webhookId != null ? 'true' : undefined}
data-flx-reply={message.type === MessageTypes.REPLY || message.messageReference != null ? 'true' : undefined}
data-flx-reply={
message.type === MessageTypes.REPLY || (message.messageReference != null && !message.isCrosspostCopy)
? 'true'
: undefined
}
data-flx-blocked={message.blocked ? 'true' : undefined}
data-flx-pinned={message.pinned ? 'true' : undefined}
data-flx-call={message.type === MessageTypes.CALL ? 'true' : undefined}
@@ -764,6 +769,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
canDeleteMessage: true,
canPinMessage: true,
canForwardMessage: true,
canCrosspostMessage: false,
shouldRenderSuppressEmbeds: true,
}}
developerMode={false}
@@ -31,13 +31,11 @@
inset-block-end: 0;
inset-inline: 0 1rem;
z-index: 1;
height: 1rem;
background: linear-gradient(to bottom, transparent, var(--messages-bottom-fade-fill));
pointer-events: none;
}
.bottomFadeStatusAtBottom {
height: var(--composer-status-safe-area);
height: 1rem;
background: linear-gradient(
to bottom,
transparent,
@@ -47,7 +45,7 @@
}
.bottomFadeStatusScrolled {
height: calc(var(--composer-status-safe-area) + 4rem);
height: 5rem;
background: linear-gradient(
to bottom,
transparent,
@@ -745,15 +745,16 @@ export const Messages = observer(function Messages({
</div>
</div>
</Scroller>
<div
className={clsx(
styles.bottomFade,
composerStatusVisible &&
(state.isAtBottom ? styles.bottomFadeStatusAtBottom : styles.bottomFadeStatusScrolled),
)}
aria-hidden="true"
data-flx="channel.messages.bottom-fade"
/>
{composerStatusVisible && (
<div
className={clsx(
styles.bottomFade,
state.isAtBottom ? styles.bottomFadeStatusAtBottom : styles.bottomFadeStatusScrolled,
)}
aria-hidden="true"
data-flx="channel.messages.bottom-fade"
/>
)}
</div>
{bottomBar}
</div>
@@ -8,11 +8,27 @@ import React from 'react';
interface UserTagProps extends React.ComponentPropsWithoutRef<'span'> {
className?: string;
system?: boolean;
variant?: 'bot' | 'system' | 'community';
size?: 'sm' | 'lg';
}
function renderTagLabel(variant: 'bot' | 'system' | 'community') {
switch (variant) {
case 'system':
return <Trans>System</Trans>;
case 'community':
return (
<Trans comment="Tag shown next to messages copied from a followed announcement channel. Keep it one short word.">
Community
</Trans>
);
default:
return <Trans>Bot</Trans>;
}
}
export const UserTag = React.forwardRef<HTMLSpanElement, UserTagProps>(
({className, system, size = 'sm', ...props}, ref) => {
({className, system, variant, size = 'sm', ...props}, ref) => {
return (
<span
className={clsx(styles.tag, size === 'lg' ? styles.tagLg : styles.tagSm, className)}
@@ -24,7 +40,7 @@ export const UserTag = React.forwardRef<HTMLSpanElement, UserTagProps>(
className={clsx(styles.text, size === 'lg' ? styles.textLg : styles.textSm)}
data-flx="channel.user-tag.text"
>
{system ? <Trans>System</Trans> : <Trans>Bot</Trans>}
{renderTagLabel(variant ?? (system ? 'system' : 'bot'))}
</span>
</span>
);
@@ -176,10 +176,11 @@ export function CompactAuthorPrefix({
<span className={styles.copyOnly} data-flx="channel.compact-message-layout.compact-author-prefix.copy-only">
{' '}
</span>
{author.bot && (
{(author.bot || message.isCrosspostCopy) && (
<UserTag
className={styles.userTagCompact}
system={author.system}
variant={message.isCrosspostCopy ? 'community' : undefined}
data-flx="channel.compact-message-layout.compact-author-prefix.user-tag-compact"
/>
)}
@@ -0,0 +1,158 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.card {
--crosspost-card-surface: var(--background-secondary);
--crosspost-card-banner-height: 7.5rem;
position: relative;
display: flex;
flex-direction: column;
width: 18.75rem;
max-width: 100%;
overflow: hidden;
background: var(--crosspost-card-surface);
color: var(--text-primary);
border: 0.0625rem solid var(--background-modifier-accent);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
}
.sheetCard {
width: 100%;
border: none;
border-radius: 0;
box-shadow: none;
}
.banner {
position: relative;
display: block;
height: var(--crosspost-card-banner-height);
flex: 0 0 auto;
overflow: hidden;
background-color: var(--background-tertiary);
}
.bannerImage {
position: absolute;
inset: 0;
display: block;
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
}
.bannerScrim {
position: absolute;
inset: 0;
display: block;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.42) 100%);
}
.icon {
position: absolute;
top: calc(var(--crosspost-card-banner-height) - 1.75rem);
left: var(--spacing-4);
z-index: 1;
box-shadow: 0 0 0 0.1875rem var(--crosspost-card-surface);
pointer-events: none;
}
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-10) var(--spacing-4) var(--spacing-4);
min-width: 0;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.25rem;
height: 1.25rem;
min-width: 0;
}
.nameText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
}
.description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.footer {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 0 0 auto;
min-width: 0;
padding: var(--spacing-3) var(--spacing-4);
border-top: 0.0625rem solid var(--background-modifier-accent);
}
.stat {
display: flex;
align-items: center;
gap: var(--count-indicator-gap);
flex: 0 1 auto;
height: 0.9rem;
min-width: 0;
}
.statDot {
height: 0.5rem;
width: 0.5rem;
border-radius: var(--radius-full);
flex: 0 0 auto;
}
.statDotOnline {
composes: statDot;
background-color: var(--status-online);
}
.statDotMembers {
composes: statDot;
background-color: var(--text-tertiary-secondary);
}
.statText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-tertiary);
font-size: 0.75rem;
line-height: 1.2;
}
.notice {
color: var(--text-tertiary);
font-size: 0.875rem;
line-height: 1.3;
}
.action {
padding: 0 var(--spacing-4) var(--spacing-4);
}
.loading {
display: flex;
justify-content: center;
padding-block: var(--spacing-1);
}
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import styles from '@app/features/channel/components/CrosspostCommunityCard.module.css';
import CrosspostSourceCards from '@app/features/channel/state/CrosspostSourceCards';
import {getCrosspostSourceDisplayName, getCrosspostSourceGuildId} from '@app/features/channel/utils/CrosspostUtils';
import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands';
import {resolveBannerTintClassName} from '@app/features/discovery/utils/DiscoveryBannerTint';
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import GuildCount from '@app/features/guild/state/GuildCount';
import Guilds from '@app/features/guild/state/Guilds';
import {JOIN_COMMUNITY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import {Button} from '@app/features/ui/button/Button';
import {Spinner} from '@app/features/ui/components/Spinner';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {msg} from '@lingui/core/macro';
import {Plural, Trans, useLingui} from '@lingui/react/macro';
import {formatNumber} from '@pkgs/number_utils/src/NumberFormatting';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useState} from 'react';
const GO_TO_COMMUNITY_DESCRIPTOR = msg({
message: 'Go to community',
comment:
'Button on the community card that opens from a published message copy, shown when the viewer is already a member of the source community.',
});
const ONLINE_COUNT_DESCRIPTOR = msg({
message: '{renderedOnlineCount} online',
comment:
'Presence summary on the community card that opens from a published message copy. renderedOnlineCount is a formatted count of online members.',
});
const COMMUNITY_UNAVAILABLE_DESCRIPTOR = msg({
message: 'This community is no longer available',
comment:
'Shown on the community card that opens from a published message copy when the source community was deleted or cannot be viewed.',
});
const COMMUNITY_LOAD_FAILED_DESCRIPTOR = msg({
message: "Couldn't load this community. Try again in a moment.",
comment:
'Shown on the community card that opens from a published message copy when loading the source community failed.',
});
const ICON_SIZE_PX = 56;
interface CommunityCardData {
id: string;
name: string;
icon: string | null;
banner: string | null;
description: string | null;
features: ReadonlySet<string> | ReadonlyArray<string>;
onlineCount: number | null;
memberCount: number | null;
isMember: boolean;
canJoin: boolean;
}
type CommunityCardView =
| {kind: 'card'; data: CommunityCardData}
| {kind: 'loading'}
| {kind: 'unavailable'}
| {kind: 'failed'};
function useCommunityCardView(message: Message, sourceGuildId: string | null): CommunityCardView {
const localGuild = sourceGuildId != null ? Guilds.getGuild(sourceGuildId) : null;
useEffect(() => {
if (sourceGuildId == null) {
return;
}
CrosspostSourceCards.ensure(sourceGuildId, message.channelId, message.id);
}, [sourceGuildId, message.channelId, message.id]);
if (sourceGuildId == null) {
return {kind: 'unavailable'};
}
const entry = CrosspostSourceCards.get(sourceGuildId);
if (localGuild != null) {
const counts = GuildCount.getCounts(localGuild.id);
const fetched = entry?.status === 'ready' ? entry.card : null;
return {
kind: 'card',
data: {
id: localGuild.id,
name: localGuild.name,
icon: localGuild.icon,
banner: localGuild.banner,
description: fetched?.description ?? null,
features: localGuild.features,
onlineCount: counts?.onlineCount ?? null,
memberCount: counts?.memberCount ?? null,
isMember: true,
canJoin: false,
},
};
}
if (entry == null || entry.status === 'loading') {
return {kind: 'loading'};
}
if (entry.status === 'unavailable') {
return {kind: 'unavailable'};
}
if (entry.status === 'failed') {
return {kind: 'failed'};
}
const {card} = entry;
return {
kind: 'card',
data: {
id: card.id,
name: card.name,
icon: card.icon ?? null,
banner: card.banner ?? null,
description: card.description ?? null,
features: card.features,
onlineCount: card.approximate_presence_count,
memberCount: card.approximate_member_count,
isMember: false,
canJoin: card.discoverable && !RuntimeConfig.singleCommunityEnabled,
},
};
}
interface CrosspostCommunityCardProps {
message: Message;
onClose: () => void;
variant?: 'popout' | 'sheet';
}
export const CrosspostCommunityCard = observer(function CrosspostCommunityCard({
message,
onClose,
variant = 'popout',
}: CrosspostCommunityCardProps) {
const {i18n} = useLingui();
const sourceGuildId = getCrosspostSourceGuildId(message);
const view = useCommunityCardView(message, sourceGuildId);
const [joining, setJoining] = useState(false);
const data = view.kind === 'card' ? view.data : null;
const guildId = data?.id ?? sourceGuildId ?? message.author.id;
const name = data?.name ?? getCrosspostSourceDisplayName(message);
const handleGoToCommunity = useCallback(() => {
onClose();
NavigationCommands.selectGuild(guildId);
}, [onClose, guildId]);
const handleJoin = useCallback(async () => {
setJoining(true);
const joined = await joinDiscoveryGuild(guildId);
setJoining(false);
if (joined) {
onClose();
}
}, [guildId, onClose]);
const bannerUrl = data?.banner ? AvatarUtils.getGuildBannerURL({id: data.id, banner: data.banner}) : null;
const locale = getCurrentLocale();
const hasCounts = data != null && data.onlineCount != null && data.memberCount != null;
const renderedOnlineCount = hasCounts ? formatNumber(data.onlineCount ?? 0, locale) : '';
const renderedMemberCount = hasCounts ? formatNumber(data.memberCount ?? 0, locale) : '';
const memberCount = data?.memberCount ?? 0;
return (
<div
className={clsx(styles.card, variant === 'sheet' && styles.sheetCard)}
data-flx="channel.crosspost-community-card.card"
>
<div className={styles.banner} aria-hidden data-flx="channel.crosspost-community-card.banner">
{bannerUrl != null ? (
<>
<span
className={styles.bannerImage}
style={{backgroundImage: `url(${bannerUrl})`}}
data-flx="channel.crosspost-community-card.banner-image"
/>
<span className={styles.bannerScrim} data-flx="channel.crosspost-community-card.banner-scrim" />
</>
) : (
<span
className={resolveBannerTintClassName(guildId)}
data-flx="channel.crosspost-community-card.banner-tint"
/>
)}
</div>
<GuildIcon
id={guildId}
name={name}
icon={data?.icon ?? null}
sizePx={ICON_SIZE_PX}
className={styles.icon}
containerProps={{'aria-hidden': true}}
data-flx="channel.crosspost-community-card.icon"
/>
<div className={styles.body} data-flx="channel.crosspost-community-card.body">
<div className={styles.titleRow} data-flx="channel.crosspost-community-card.title-row">
<span className={styles.nameText} data-flx="channel.crosspost-community-card.name">
{name}
</span>
{data != null && (
<GuildBadge
features={data.features}
tooltipPosition="bottom"
data-flx="channel.crosspost-community-card.guild-badge"
/>
)}
</div>
{data?.description && (
<span className={styles.description} data-flx="channel.crosspost-community-card.description">
{data.description}
</span>
)}
{view.kind === 'loading' && (
<div className={styles.loading} data-flx="channel.crosspost-community-card.loading">
<Spinner size="small" data-flx="channel.crosspost-community-card.spinner" />
</div>
)}
{view.kind === 'unavailable' && (
<span className={styles.notice} data-flx="channel.crosspost-community-card.notice">
{i18n._(COMMUNITY_UNAVAILABLE_DESCRIPTOR)}
</span>
)}
{view.kind === 'failed' && (
<span className={styles.notice} data-flx="channel.crosspost-community-card.notice--2">
{i18n._(COMMUNITY_LOAD_FAILED_DESCRIPTOR)}
</span>
)}
</div>
{hasCounts && (
<div className={styles.footer} data-flx="channel.crosspost-community-card.footer">
<span className={styles.stat} data-flx="channel.crosspost-community-card.stat">
<span
className={styles.statDotOnline}
aria-hidden
data-flx="channel.crosspost-community-card.stat-dot-online"
/>
<span className={styles.statText} data-flx="channel.crosspost-community-card.stat-text">
{i18n._(ONLINE_COUNT_DESCRIPTOR, {renderedOnlineCount})}
</span>
</span>
<span className={styles.stat} data-flx="channel.crosspost-community-card.stat--2">
<span
className={styles.statDotMembers}
aria-hidden
data-flx="channel.crosspost-community-card.stat-dot-members"
/>
<span className={styles.statText} data-flx="channel.crosspost-community-card.stat-text--2">
<Trans comment="Member count on the community card that opens from a published message copy. renderedMemberCount is the formatted count.">
{renderedMemberCount}{' '}
<Plural
value={memberCount}
one="member"
other="members"
data-flx="channel.crosspost-community-card.plural"
/>
</Trans>
</span>
</span>
</div>
)}
{data?.isMember && (
<div className={styles.action} data-flx="channel.crosspost-community-card.action">
<Button
variant="primary"
fitContainer
onClick={handleGoToCommunity}
data-flx="channel.crosspost-community-card.button.go-to-community"
>
{i18n._(GO_TO_COMMUNITY_DESCRIPTOR)}
</Button>
</div>
)}
{data != null && !data.isMember && data.canJoin && (
<div className={styles.action} data-flx="channel.crosspost-community-card.action--2">
<Button
variant="primary"
fitContainer
submitting={joining}
onClick={handleJoin}
data-flx="channel.crosspost-community-card.button.join"
>
{i18n._(JOIN_COMMUNITY_DESCRIPTOR)}
</Button>
</div>
)}
</div>
);
});
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {CrosspostCommunityCard} from '@app/features/channel/components/CrosspostCommunityCard';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {WebhookContextMenu} from '@app/features/ui/action_menu/WebhookContextMenu';
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {Popout} from '@app/features/ui/popover/PopoverPopout';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {observer} from 'mobx-react-lite';
import React, {useCallback, useState} from 'react';
type CrosspostChildProps = React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>;
interface CrosspostCommunityPopoutProps {
message: Message;
children: React.ReactElement<CrosspostChildProps>;
onPopoutOpen?: () => void;
onPopoutClose?: () => void;
}
export const CrosspostCommunityPopout = observer(function CrosspostCommunityPopout({
message,
children,
onPopoutOpen,
onPopoutClose,
}: CrosspostCommunityPopoutProps) {
const [sheetOpen, setSheetOpen] = useState(false);
const webhookId = message.webhookId;
const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!webhookId) return;
event.preventDefault();
event.stopPropagation();
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<WebhookContextMenu
webhookId={webhookId}
onClose={onClose}
data-flx="channel.crosspost-community-popout.handle-context-menu.webhook-context-menu"
/>
));
},
[webhookId],
);
const handleSheetClose = useCallback(() => setSheetOpen(false), []);
if (MobileLayout.enabled) {
const {onClick: originalOnClick} = children.props;
const clonedChild = React.cloneElement(children, {
onClick: (event: React.MouseEvent<HTMLElement>) => {
originalOnClick?.(event);
setSheetOpen(true);
},
onContextMenu: handleContextMenu,
});
return (
<>
{clonedChild}
{sheetOpen && (
<BottomSheet
isOpen={true}
onClose={handleSheetClose}
disablePadding={true}
disableDefaultHeader={true}
data-flx="channel.crosspost-community-popout.bottom-sheet"
>
<CrosspostCommunityCard
message={message}
onClose={handleSheetClose}
variant="sheet"
data-flx="channel.crosspost-community-popout.crosspost-community-card"
/>
</BottomSheet>
)}
</>
);
}
return (
<Popout
render={({onClose}) => (
<CrosspostCommunityCard
message={message}
onClose={onClose}
data-flx="channel.crosspost-community-popout.crosspost-community-card--2"
/>
)}
position="right-start"
animationType="profile-slide"
constrainHeight={false}
freezePosition
keepOpenOnTargetUnmount
onOpen={onPopoutOpen}
onClose={onPopoutClose}
data-flx="channel.crosspost-community-popout.popout"
>
{React.cloneElement(children, {onContextMenu: handleContextMenu})}
</Popout>
);
});
@@ -0,0 +1,80 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem 1rem;
margin-block-start: 0.25rem;
min-inline-size: 0;
font-size: 0.875rem;
line-height: 1.25rem;
user-select: none;
-webkit-user-select: none;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-inline-size: 0;
padding: 0.375rem 0.625rem;
border-radius: var(--radius-md);
background: var(--background-secondary);
color: var(--text-primary);
}
.icon {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
color: var(--text-tertiary);
}
.label {
min-inline-size: 0;
font-weight: 400;
}
.publishButton,
.dismissButton,
.hideForeverButton {
display: inline-flex;
align-items: center;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
font: inherit;
cursor: pointer;
flex-shrink: 0;
}
.publishButton {
color: var(--text-link);
font-weight: 500;
}
.dismissButton {
color: var(--text-tertiary);
}
.dismissIcon {
width: 1.125rem;
height: 1.125rem;
}
.hideForeverButton {
color: var(--text-chat-muted);
}
@media (hover: hover) {
.publishButton:hover,
.hideForeverButton:hover {
text-decoration: underline;
}
.dismissButton:hover {
color: var(--text-primary);
}
}
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/channel/components/CrosspostPublishNudge.module.css';
import {requestMessageCrosspost, useMessagePermissions} from '@app/features/channel/components/MessageActionUtils';
import type {Channel} from '@app/features/channel/models/Channel';
import PublishNudge from '@app/features/channel/state/PublishNudge';
import {DISMISS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {AnnouncementChannelIcon} from '@app/features/ui/components/icons/AnnouncementChannelIcon';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {XCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
const SHARE_WITH_FOLLOWERS_DESCRIPTOR = msg({
message: 'Not sent to followers yet.',
comment:
'Prompt under the newest message the current user sent in an announcement channel, suggesting they publish it to the communities that follow the channel.',
});
const PUBLISH_DESCRIPTOR = msg({
message: 'Publish',
comment:
'Link-style button in the prompt under a new announcement channel message. Publishes the message to the communities that follow the channel.',
});
const DONT_SHOW_AGAIN_DESCRIPTOR = msg({
message: "Don't show again",
comment:
'Muted button next to the publish prompt under a new announcement channel message. Hides the prompt for every future message.',
});
interface CrosspostPublishNudgeProps {
message: Message;
channel: Channel;
}
export const CrosspostPublishNudge = observer(({message, channel}: CrosspostPublishNudgeProps) => {
const {i18n} = useLingui();
const permissions = useMessagePermissions(message, channel);
if (!PublishNudge.shouldShow(message, permissions?.canCrosspostMessage ?? false)) {
return null;
}
const handlePublish = (event: React.MouseEvent) => {
requestMessageCrosspost(message, i18n, {shiftKey: event.shiftKey});
};
return (
<div className={styles.row} data-message-copy-hidden="true" data-flx="channel.crosspost-publish-nudge.row">
<div className={styles.pill} data-flx="channel.crosspost-publish-nudge.pill">
<AnnouncementChannelIcon className={styles.icon} data-flx="channel.crosspost-publish-nudge.icon" />
<span className={styles.label} data-flx="channel.crosspost-publish-nudge.label">
{i18n._(SHARE_WITH_FOLLOWERS_DESCRIPTOR)}
</span>
<FocusRing data-flx="channel.crosspost-publish-nudge.publish-focus-ring">
<button
type="button"
className={styles.publishButton}
onClick={handlePublish}
data-flx="channel.crosspost-publish-nudge.publish-button.publish"
>
{i18n._(PUBLISH_DESCRIPTOR)}
</button>
</FocusRing>
<FocusRing data-flx="channel.crosspost-publish-nudge.dismiss-focus-ring">
<button
type="button"
className={styles.dismissButton}
aria-label={i18n._(DISMISS_DESCRIPTOR)}
onClick={() => PublishNudge.dismiss(message.id)}
data-flx="channel.crosspost-publish-nudge.dismiss-button.dismiss"
>
<XCircleIcon
weight="fill"
className={styles.dismissIcon}
aria-hidden="true"
data-flx="channel.crosspost-publish-nudge.dismiss-icon"
/>
</button>
</FocusRing>
</div>
<FocusRing data-flx="channel.crosspost-publish-nudge.hide-forever-focus-ring">
<button
type="button"
className={styles.hideForeverButton}
onClick={PublishNudge.hideForever}
data-flx="channel.crosspost-publish-nudge.hide-forever-button.hide-forever"
>
{i18n._(DONT_SHOW_AGAIN_DESCRIPTOR)}
</button>
</FocusRing>
</div>
);
});
@@ -84,8 +84,17 @@
display: none;
}
.button.disabled {
color: var(--text-chat-muted);
cursor: default;
}
.button.disabled .actionBarIcon {
opacity: 0.5;
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.button:not(.active):not(.danger):hover {
.button:not(.active):not(.danger):not(.disabled):hover {
color: var(--text-primary);
background: var(--message-action-bar-hover-background);
}
@@ -38,6 +38,7 @@ import {
AddReactionIcon,
CopyIdIcon,
CopyLinkIcon,
CrosspostIcon,
DebugMessageIcon,
DeleteIcon,
EditMessageIcon,
@@ -82,6 +83,16 @@ const DEBUG_MESSAGE_DESCRIPTOR = msg({
message: 'Debug message',
comment: 'Developer-mode item in the message action bar overflow menu. Opens the message debug modal.',
});
const PUBLISH_DESCRIPTOR = msg({
message: 'Publish',
comment:
'Tooltip on the publish button in the inline message hover action bar. Publishing sends an announcement channel message to every channel that follows it.',
});
const PUBLISHED_DESCRIPTOR = msg({
message: 'Published',
comment:
'Tooltip on the disabled publish button in the inline message hover action bar for a message that was already published.',
});
const FORWARD_DESCRIPTOR = msg({
message: 'Forward',
comment: 'Tooltip on the forward button in the inline message hover action bar.',
@@ -95,18 +106,22 @@ interface MessageActionBarButtonProps {
danger?: boolean;
isActive?: boolean;
hidden?: boolean;
disabled?: boolean;
dataAction?: string;
}
const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageActionBarButtonProps>(
({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, dataAction}, ref) => {
({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, disabled, dataAction}, ref) => {
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (disabled) {
return;
}
onClick?.(event);
},
[onClick],
[onClick, disabled],
);
const handlePointerDownCapture = useCallback(
(event: React.PointerEvent) => {
@@ -115,8 +130,8 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
[onPointerDownCapture],
);
const buttonClassName = useMemo(
() => clsx(styles.button, danger && styles.danger, isActive && styles.active),
[danger, isActive],
() => clsx(styles.button, danger && styles.danger, isActive && styles.active, disabled && styles.disabled),
[danger, isActive, disabled],
);
return (
<Tooltip text={label} data-flx="channel.message-action-bar.message-action-bar-button.tooltip">
@@ -125,6 +140,7 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
type="button"
ref={ref}
aria-label={label}
aria-disabled={disabled || undefined}
hidden={hidden}
onClick={handleClick}
onPointerDownCapture={handlePointerDownCapture}
@@ -234,6 +250,7 @@ interface MessageActionBarCoreProps {
canDeleteMessage: boolean;
canPinMessage: boolean;
canForwardMessage: boolean;
canCrosspostMessage: boolean;
shouldRenderSuppressEmbeds: boolean;
};
developerMode: boolean;
@@ -259,8 +276,15 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
const shouldListenForShift = showShiftExpand && showMessageActionBar && !onlyMoreButton && !keyboardModeEnabled;
const shiftPressed = useShiftKey(shouldListenForShift);
const showFullActions = shouldListenForShift && shiftPressed;
const {canSendMessages, canAddReactions, canEditMessage, canDeleteMessage, canPinMessage, canForwardMessage} =
permissions;
const {
canSendMessages,
canAddReactions,
canEditMessage,
canDeleteMessage,
canPinMessage,
canForwardMessage,
canCrosspostMessage,
} = permissions;
const showsEditInTail = message.isUserMessage() && !message.messageSnapshots && canEditMessage;
const supportsInteractiveActions = useMemo(() => !isClientSystemMessage(message), [message]);
const handlers = useMemo(
@@ -609,6 +633,20 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.forward"
/>
)}
{supportsInteractiveActions && canCrosspostMessage && (
<MessageActionBarButton
icon={
<CrosspostIcon
size={20}
data-flx="channel.message-action-bar.message-action-bar-core.crosspost-icon"
/>
}
label={message.isCrossposted ? i18n._(PUBLISHED_DESCRIPTOR) : i18n._(PUBLISH_DESCRIPTOR)}
disabled={message.isCrossposted}
onClick={handlers.handleCrosspostMessage}
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.crosspost"
/>
)}
{showFullActions && canDeleteMessage && (
<MessageActionBarButton
danger={true}
@@ -41,6 +41,7 @@ import {
CopyIdIcon,
CopyLinkIcon,
CopyMessageTextIcon,
CrosspostIcon,
DebugMessageIcon,
DeleteIcon,
EditMessageIcon,
@@ -79,6 +80,11 @@ const REMOVE_ALL_REACTIONS_DESCRIPTOR = msg({
message: 'Remove all reactions',
comment: 'Destructive message context menu item for moderators that clears every reaction on a message.',
});
const PUBLISH_MESSAGE_DESCRIPTOR = msg({
message: 'Publish',
comment:
'Message context menu item in an announcement channel. Publishing sends the message to every channel that follows this channel.',
});
const FORWARD_DESCRIPTOR = msg({
message: 'Forward',
comment: 'Message context menu item that opens the forward-to-channel picker.',
@@ -120,6 +126,7 @@ export const messageActionMenuItemIds = {
removeAllReactions: 'remove_all_reactions',
reply: 'reply',
forward: 'forward',
crosspost: 'message_crosspost',
edit: 'edit',
pinMessage: 'message_pin',
bookmarkMessage: 'message_bookmark',
@@ -292,6 +299,14 @@ export const useMessageActionMenuData = (
),
});
}
if (supportsInteractiveActions && permissions?.canCrosspostMessage && !message.isCrossposted) {
interactionActions.push({
id: messageActionMenuItemIds.crosspost,
icon: <CrosspostIcon size={20} data-flx="channel.message-action-menu.groups.crosspost-icon" />,
label: i18n._(PUBLISH_MESSAGE_DESCRIPTOR),
onClick: handlers.handleCrosspostMessage,
});
}
if (message.isCurrentUserAuthor() && message.isUserMessage() && !message.messageSnapshots) {
interactionActions.push({
id: messageActionMenuItemIds.edit,
@@ -16,6 +16,7 @@ import * as MessageCommands from '@app/features/messaging/commands/MessageComman
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
import * as SavedMessageCommands from '@app/features/messaging/commands/SavedMessageCommands';
import {ForwardModal, type ForwardModalSuccess} from '@app/features/messaging/components/modals/ForwardModal';
import {MessageCrosspostConfirmModal} from '@app/features/messaging/components/modals/MessageCrosspostConfirmModal';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageReply from '@app/features/messaging/state/MessageReply';
@@ -37,6 +38,7 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
import Users from '@app/features/user/state/Users';
import TtsUtils from '@app/features/voice/utils/VoiceTtsUtils';
import {
ChannelTypes,
isMessageTypeDeletable,
MessageFlags,
MessageStates,
@@ -167,6 +169,7 @@ export interface MessagePermissions {
canDeleteAttachment: boolean;
canPinMessage: boolean;
canForwardMessage: boolean;
canCrosspostMessage: boolean;
canSuppressEmbeds: boolean;
shouldRenderSuppressEmbeds: boolean;
}
@@ -226,6 +229,18 @@ function getMessagePermissionsForChannel(message: Message, channel: Channel): Me
(isDM ? true : Permission.can(Permissions.PIN_MESSAGES, {channelId: message.channelId}));
const canForwardMessage =
!interactionsBlocked && !sendMessageDisabled && canForwardMessageFromChannel(message, channel, isDM);
const canCrosspostMessage =
!interactionsBlocked &&
!sendMessageDisabled &&
!isDM &&
channel.type === ChannelTypes.GUILD_ANNOUNCEMENT &&
message.type === MessageTypes.DEFAULT &&
message.state === MessageStates.SENT &&
!message.messageSnapshots?.length &&
!message.isCrosspostCopy &&
passesVerification &&
Permission.can(Permissions.SEND_MESSAGES, {channelId: message.channelId}) &&
(message.isCurrentUserAuthor() || Permission.can(Permissions.MANAGE_MESSAGES, {channelId: message.channelId}));
const canSuppressEmbeds =
!interactionsBlocked &&
!sendMessageDisabled &&
@@ -243,6 +258,7 @@ function getMessagePermissionsForChannel(message: Message, channel: Channel): Me
canDeleteAttachment,
canPinMessage,
canForwardMessage,
canCrosspostMessage,
canSuppressEmbeds,
shouldRenderSuppressEmbeds,
};
@@ -297,6 +313,7 @@ export interface MessageActionHandlers {
handleRetryMessage: () => void;
handleFailedMessageDelete: () => void;
handleForward: () => void;
handleCrosspostMessage: (event?: React.MouseEvent | React.KeyboardEvent) => void;
handleRemoveAllReactions: () => void;
handleMarkAsUnread: () => void;
}
@@ -382,6 +399,14 @@ export function createMessageActionHandlers(
}
requestMessageForward(message, sourceChannel);
};
const handleCrosspostMessage = (event?: React.MouseEvent | React.KeyboardEvent) => {
const crosspostMessage = () => requestMessageCrosspost(message, i18n, {shiftKey: Boolean(event?.shiftKey)});
if (onClose) {
ModalCommands.runAfterBottomSheetClose(onClose, crosspostMessage);
return;
}
crosspostMessage();
};
const handleRemoveAllReactions = () => {
if (onClose) {
ModalCommands.runAfterBottomSheetClose(onClose, () => requestRemoveAllReactions(message, i18n));
@@ -406,6 +431,7 @@ export function createMessageActionHandlers(
handleRetryMessage,
handleFailedMessageDelete,
handleForward,
handleCrosspostMessage,
handleRemoveAllReactions,
handleMarkAsUnread,
};
@@ -471,6 +497,24 @@ export function requestMessagePin(message: Message, i18n: I18n, options: {shiftK
);
}
export function requestMessageCrosspost(message: Message, i18n: I18n, options: {shiftKey?: boolean} = {}): void {
if (message.isCrossposted) {
return;
}
if (options.shiftKey) {
void MessageCommands.crosspost(i18n, message.channelId, message.id);
return;
}
ModalCommands.push(
modal(() => (
<MessageCrosspostConfirmModal
message={message}
data-flx="channel.message-action-utils.request-message-crosspost.message-crosspost-confirm-modal"
/>
)),
);
}
export function requestRemoveAllReactions(message: Message, i18n: I18n): void {
ModalCommands.push(
modal(() => (
@@ -57,7 +57,14 @@ import type {
MessageStickerItem,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowBendUpRightIcon, CaretRightIcon, HashIcon, NotePencilIcon, SpeakerHighIcon} from '@phosphor-icons/react';
import {
ArrowBendUpRightIcon,
CaretRightIcon,
HashIcon,
MegaphoneSimpleIcon,
NotePencilIcon,
SpeakerHighIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -125,7 +132,7 @@ const SpoileredUrlEmbed = observer(function SpoileredUrlEmbed({
const ForwardedFromSource = observer(({message}: {message: Message}) => {
const {sourceChannel, sourceGuild, sourceUser, hasAccessToSource, displayName} = useForwardedMessageContext(message);
const handleJumpToOriginal = useCallback(() => {
if (message.messageReference && sourceChannel) {
if (message.messageReference?.message_id && sourceChannel) {
goToMessage(message.messageReference.channel_id, message.messageReference.message_id, {
returnToMessageId: message.id,
returnChannelId: message.channelId,
@@ -184,6 +191,16 @@ const ForwardedFromSource = observer(({message}: {message: Message}) => {
/>
);
}
if (sourceChannel.type === ChannelTypes.GUILD_ANNOUNCEMENT) {
return (
<MegaphoneSimpleIcon
className={styles.forwardedSourceIcon}
weight="bold"
size={iconSize}
data-flx="channel.message-attachments.render-channel-icon.forwarded-source-icon--4"
/>
);
}
return (
<HashIcon
className={styles.forwardedSourceIcon}
@@ -103,10 +103,11 @@ export const MessageAuthorInfo = observer((props: MessageAuthorInfoProps) => {
<span className={styles.messageAuthorPart} data-flx="channel.message-author-info.message-author-part">
{timeoutIndicator}
{username}
{author.bot && (
{(author.bot || message.isCrosspostCopy) && (
<UserTag
className={styles.userTagOffset}
system={author.system}
variant={message.isCrosspostCopy ? 'community' : undefined}
data-flx="channel.message-author-info.user-tag-offset"
/>
)}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {CrosspostCommunityPopout} from '@app/features/channel/components/CrosspostCommunityPopout';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
@@ -29,6 +30,29 @@ export const MessageAvatar = observer(
const onPopoutToggle = useMaybeMessageViewContext()?.onPopoutToggle;
const handlePopoutOpen = useCallback(() => onPopoutToggle?.(true), [onPopoutToggle]);
const handlePopoutClose = useCallback(() => onPopoutToggle?.(false), [onPopoutToggle]);
if (message.isCrosspostCopy) {
return (
<CrosspostCommunityPopout
message={message}
onPopoutOpen={handlePopoutOpen}
onPopoutClose={handlePopoutClose}
data-flx="channel.message-avatar.crosspost-community-popout"
>
<FocusRing data-flx="channel.message-avatar.focus-ring--2">
<Avatar
user={user}
size={size}
className={className}
forceAnimate={isHovering}
guildId={guildId}
data-user-id={user.id}
data-guild-id={guildId}
data-flx="channel.message-avatar.crosspost-author-avatar"
/>
</FocusRing>
</CrosspostCommunityPopout>
);
}
return (
<PreloadableUserPopout
user={user}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {CrosspostCommunityPopout} from '@app/features/channel/components/CrosspostCommunityPopout';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout';
import type {Guild} from '@app/features/guild/models/Guild';
@@ -50,6 +51,36 @@ export const MessageUsername = observer(
(e.currentTarget as HTMLElement).click();
}, []);
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
const usernameNode = (
<FocusRing data-flx="channel.message-username.focus-ring">
{/* biome-ignore lint/a11y/noStaticElementInteractions: the username span is only keyboard-interactive in keyboard mode (role="button"/tabIndex set conditionally); pointer/popout/context-menu interactions are handled by the wrapping PreloadableUserPopout. */}
<span
className={clsx(className, contextMenuOpen && styles.contextMenuUnderline)}
style={{color}}
data-user-id={user.id}
data-guild-id={guild?.id}
tabIndex={keyboardModeEnabled ? 0 : undefined}
role={keyboardModeEnabled ? 'button' : undefined}
ref={usernameRef}
onKeyDown={handleKeyDown}
data-flx="channel.message-username.context-menu-underline.key-down"
>
{displayName}
</span>
</FocusRing>
);
if (message.isCrosspostCopy) {
return (
<CrosspostCommunityPopout
message={message}
onPopoutOpen={handlePopoutOpen}
onPopoutClose={handlePopoutClose}
data-flx="channel.message-username.crosspost-community-popout"
>
{usernameNode}
</CrosspostCommunityPopout>
);
}
return (
<PreloadableUserPopout
user={user}
@@ -65,22 +96,7 @@ export const MessageUsername = observer(
onPopoutClose={handlePopoutClose}
data-flx="channel.message-username.preloadable-user-popout"
>
<FocusRing data-flx="channel.message-username.focus-ring">
{/* biome-ignore lint/a11y/noStaticElementInteractions: the username span is only keyboard-interactive in keyboard mode (role="button"/tabIndex set conditionally); pointer/popout/context-menu interactions are handled by the wrapping PreloadableUserPopout. */}
<span
className={clsx(className, contextMenuOpen && styles.contextMenuUnderline)}
style={{color}}
data-user-id={user.id}
data-guild-id={guild?.id}
tabIndex={keyboardModeEnabled ? 0 : undefined}
role={keyboardModeEnabled ? 'button' : undefined}
ref={usernameRef}
onKeyDown={handleKeyDown}
data-flx="channel.message-username.context-menu-underline.key-down"
>
{displayName}
</span>
</FocusRing>
{usernameNode}
</PreloadableUserPopout>
);
},
@@ -19,6 +19,7 @@ export interface MessagePreviewPermissions {
canDeleteAttachment: boolean;
canPinMessage: boolean;
canForwardMessage: boolean;
canCrosspostMessage: boolean;
canSuppressEmbeds: boolean;
shouldRenderSuppressEmbeds: boolean;
}
@@ -70,7 +70,7 @@ export const ReplyPreview = observer(
});
}
}, [referenceChannelId, message.channelId, message.id, message.messageReference]);
if (!message.messageReference) return null;
if (!message.messageReference || message.isCrosspostCopy) return null;
if (resolution.state !== MessageReferenceState.LOADED) {
const isDeleted = resolution.state === MessageReferenceState.DELETED;
return (
@@ -4,6 +4,7 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
import {SILENT_MENTION} from '@app/features/app/config/I18nDisplayConstants';
import {UserTag} from '@app/features/channel/components/ChannelUserTag';
import {CompactAuthorPrefix, CompactMessageLayout} from '@app/features/channel/components/CompactMessageLayout';
import {CrosspostPublishNudge} from '@app/features/channel/components/CrosspostPublishNudge';
import {EditingMessageInput} from '@app/features/channel/components/EditingMessageInput';
import {isMediaOnlyEmbed} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import {MessageAttachments} from '@app/features/channel/components/MessageAttachments';
@@ -25,6 +26,7 @@ import * as MessageCommands from '@app/features/messaging/commands/MessageComman
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {parse} from '@app/features/messaging/components/markdown/renderers';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import {hasStyleableMessageText} from '@app/features/messaging/utils/FailedMessageDisplayUtils';
import {buildMessageContentCopyText} from '@app/features/messaging/utils/MessageCopyTextUtils';
@@ -47,6 +49,7 @@ import * as DateUtils from '@app/features/user/utils/DateFormatting';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {MessageFlags, MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowsClockwiseIcon, BellSlashIcon, EyeIcon, WarningCircleIcon} from '@phosphor-icons/react';
@@ -59,6 +62,16 @@ const JUMP_TO_MESSAGE_FROM_SENT_DESCRIPTOR = msg({
comment:
'Label in the channel and chat user message. Preserve {displayName}, {formattedDate}; they are inserted by code.',
});
const SOURCE_DELETED_CONTENT_DESCRIPTOR = msg({
message: '[Original message deleted]',
comment:
'Replaces the text of a copy of a published announcement after the original was deleted in the announcement channel. Keep the square brackets.',
});
function getDisplayedContent(message: Message, i18n: I18n): string {
return message.isCrosspostSourceDeleted ? i18n._(SOURCE_DELETED_CONTENT_DESCRIPTOR) : message.content;
}
const EDITED_DESCRIPTOR = msg({
message: '(edited)',
comment: 'Button or menu action label in the channel and chat user message. Keep it concise.',
@@ -213,15 +226,17 @@ export const UserMessage = observer(() => {
return;
}
if (canSubmitEmptyMessageEdit(message)) {
finishEditing();
void MessageCommands.edit(
channel.id,
message.id,
'',
undefined,
message._allowedMentions,
buildExistingAttachmentEditReferences(message),
);
MessageCommands.confirmPublishedMessageEdit(i18n, message, () => {
finishEditing();
void MessageCommands.edit(
channel.id,
message.id,
'',
undefined,
message._allowedMentions,
buildExistingAttachmentEditReferences(message),
);
});
return;
}
handleDelete();
@@ -230,10 +245,13 @@ export const UserMessage = observer(() => {
if (checkCustomEmojiAvailability(content)) {
return;
}
finishEditing();
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
MessageCommands.confirmPublishedMessageEdit(i18n, message, () => {
finishEditing();
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
});
},
[
i18n,
channel.id,
handleDelete,
message,
@@ -295,7 +313,7 @@ export const UserMessage = observer(() => {
{...messageContentCopyBlockProps(contentCopyText)}
>
<SafeMarkdown
content={message.content}
content={getDisplayedContent(message, i18n)}
options={markdownOptions}
data-flx="channel.user-message.render-message-content.safe-markdown"
/>
@@ -430,7 +448,7 @@ export const UserMessage = observer(() => {
{...messageContentCopyBlockProps(contentCopyText)}
>
<SafeMarkdown
content={message.content}
content={getDisplayedContent(message, i18n)}
options={markdownOptions}
data-flx="channel.user-message.safe-markdown"
/>
@@ -475,7 +493,7 @@ export const UserMessage = observer(() => {
if (messageDisplayCompact) {
return (
<SpoilerSyncProvider data-flx="channel.user-message.spoiler-sync-provider--2">
{message.messageReference && message.messageReference.type === 0 && (
{message.messageReference && message.messageReference.type === 0 && !message.isCrosspostCopy && (
<ReplyPreview
message={message}
channelId={channel.id}
@@ -516,7 +534,7 @@ export const UserMessage = observer(() => {
{showMetadata && compactAuthorPrefix}
{!shouldHideContent && (
<SafeMarkdown
content={message.content}
content={getDisplayedContent(message, i18n)}
options={markdownOptions}
data-flx="channel.user-message.safe-markdown--2"
/>
@@ -545,6 +563,13 @@ export const UserMessage = observer(() => {
</CompactMessageLayout>
<div className={styles.container} data-flx="channel.user-message.container--2">
<MessageAttachments data-flx="channel.user-message.message-attachments--2" />
{!previewContext && (
<CrosspostPublishNudge
message={message}
channel={channel}
data-flx="channel.user-message.crosspost-publish-nudge"
/>
)}
{renderFailedFooter()}
</div>
</SpoilerSyncProvider>
@@ -552,7 +577,7 @@ export const UserMessage = observer(() => {
}
return (
<SpoilerSyncProvider data-flx="channel.user-message.spoiler-sync-provider--3">
{message.messageReference && message.messageReference.type === 0 && (
{message.messageReference && message.messageReference.type === 0 && !message.isCrosspostCopy && (
<ReplyPreview
message={message}
channelId={channel.id}
@@ -584,10 +609,11 @@ export const UserMessage = observer(() => {
previewName={previewOverrides?.displayName}
data-flx="channel.user-message.message-username--2"
/>
{author.bot && (
{(author.bot || message.isCrosspostCopy) && (
<UserTag
className={styles.userTagOffset}
system={author.system}
variant={message.isCrosspostCopy ? 'community' : undefined}
data-flx="channel.user-message.user-tag-offset--2"
/>
)}
@@ -683,10 +709,11 @@ export const UserMessage = observer(() => {
previewName={previewOverrides?.displayName}
data-flx="channel.user-message.message-username--3"
/>
{author.bot && (
{(author.bot || message.isCrosspostCopy) && (
<UserTag
className={styles.userTagOffset}
system={author.system}
variant={message.isCrosspostCopy ? 'community' : undefined}
data-flx="channel.user-message.user-tag-offset--3"
/>
)}
@@ -762,6 +789,13 @@ export const UserMessage = observer(() => {
</span>
</TimestampWithTooltip>
))}
{!previewContext && (
<CrosspostPublishNudge
message={message}
channel={channel}
data-flx="channel.user-message.crosspost-publish-nudge--2"
/>
)}
{renderFailedFooter()}
</div>
</SpoilerSyncProvider>
@@ -3,8 +3,10 @@
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
import styles from '@app/features/channel/components/barriers/BarrierComponents.module.css';
import {openChannelFollowModal} from '@app/features/channel/components/modals/ChannelFollowModal';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import textareaStyles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import {FOLLOW_DESCRIPTOR} from '@app/features/channel/utils/ChannelFollowUtils';
import {CLAIM_ACCOUNT_DESCRIPTOR, VERIFY_EMAIL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {unblockUser} from '@app/features/relationship/utils/RelationshipActionUtils';
@@ -20,6 +22,7 @@ import {
ClockIcon,
EnvelopeSimpleIcon,
InfoIcon,
MegaphoneSimpleIcon,
PhoneIcon,
ProhibitIcon,
ShieldWarningIcon,
@@ -312,6 +315,36 @@ export const TimeoutBarrier = observer(({initialTimeRemaining = 0}: TimedBarrier
/>
);
});
export const AnnouncementFollowBarrier = observer(({channelId}: {channelId: string}) => {
const {i18n} = useLingui();
return (
<BarrierBase
message={
<Trans comment="Shown instead of the message composer in an announcement channel where the user cannot send messages.">
Follow to get these announcements in a channel you choose.
</Trans>
}
icon={
<MegaphoneSimpleIcon
size={remFromPx(18)}
weight="fill"
data-flx="channel.barriers.barrier-components.announcement-follow-barrier.megaphone-simple-icon"
/>
}
action={
<Button
variant="secondary"
small={true}
onClick={() => openChannelFollowModal(channelId)}
data-flx="channel.barriers.barrier-components.announcement-follow-barrier.button.follow"
>
{i18n._(FOLLOW_DESCRIPTOR)}
</Button>
}
data-flx="channel.barriers.barrier-components.announcement-follow-barrier.barrier-base"
/>
);
});
export const DefaultBarrier = observer(() => {
return (
<BarrierBase
@@ -55,11 +55,13 @@ import {MoreOptionsSheet} from '@app/features/channel/components/bottomsheets/ch
import {NotificationSettingsSheet} from '@app/features/channel/components/bottomsheets/channel_details_bottom_sheet/NotificationSettingsSheet';
import {QuickActionButton} from '@app/features/channel/components/bottomsheets/channel_details_bottom_sheet/QuickActionButton';
import {createMuteConfig} from '@app/features/channel/components/MuteOptions';
import {ChannelFollowModal} from '@app/features/channel/components/modals/ChannelFollowModal';
import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal';
import {CreateDMModal} from '@app/features/channel/components/modals/CreateDMModal';
import {EditGroupModal} from '@app/features/channel/components/modals/EditGroupModal';
import {GroupInvitesModal} from '@app/features/channel/components/modals/GroupInvitesModal';
import {useDeleteMyMessagesInChannel} from '@app/features/channel/hooks/useDeleteMyMessagesInChannel';
import {canFollowAnnouncementChannel, FOLLOW_DESCRIPTOR} from '@app/features/channel/utils/ChannelFollowUtils';
import {
CLOSE_DM_DESCRIPTOR,
DELETE_CHANNEL_DESCRIPTOR,
@@ -76,6 +78,7 @@ import {useLeaveGroup} from '@app/features/guild/hooks/useLeaveGroup';
import Guilds from '@app/features/guild/state/Guilds';
import {
ADDED_TO_FAVORITES_TOAST_DESCRIPTOR,
ANNOUNCEMENT_CHANNEL_DESCRIPTOR,
CHANNEL_DELETED_DESCRIPTOR,
LINK_COPIED_TO_CLIPBOARD_DESCRIPTOR,
PERSONAL_NOTES_DESCRIPTOR,
@@ -119,6 +122,7 @@ import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {Trans, useLingui} from '@lingui/react/macro';
import {MegaphoneSimpleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
@@ -156,6 +160,8 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
switch (channel.type) {
case ChannelTypes.GUILD_TEXT:
return i18n._(TEXT_CHANNEL_DESCRIPTOR);
case ChannelTypes.GUILD_ANNOUNCEMENT:
return i18n._(ANNOUNCEMENT_CHANNEL_DESCRIPTOR);
case ChannelTypes.GUILD_VOICE:
return i18n._(VOICE_CHANNEL_DESCRIPTOR);
case ChannelTypes.DM:
@@ -217,6 +223,17 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
)),
);
}, [channel.id, onClose]);
const handleFollowChannel = useCallback(() => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<ChannelFollowModal
channelId={channel.id}
data-flx="channel.channel-details-bottom-sheet.handle-follow-channel.channel-follow-modal"
/>
)),
);
}, [channel.id, onClose]);
const handleCopyLink = useCallback(() => {
const channelLink = buildChannelLink({guildId: channel.guildId, channelId: channel.id});
TextCopyCommands.copy(i18n, channelLink);
@@ -551,6 +568,16 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
onClick={() => setSearchSheetOpen(true)}
data-flx="channel.channel-details-bottom-sheet.quick-action-button.search-click"
/>
{canFollowAnnouncementChannel(channel) && (
<QuickActionButton
icon={
<MegaphoneSimpleIcon size={20} data-flx="channel.channel-details-bottom-sheet.follow-icon" />
}
label={i18n._(FOLLOW_DESCRIPTOR)}
onClick={handleFollowChannel}
data-flx="channel.channel-details-bottom-sheet.quick-action-button.follow-click"
/>
)}
<QuickActionButton
icon={
<MoreOptionsVerticalIcon
@@ -3,6 +3,7 @@
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {
ANNOUNCEMENT_CHANNEL_DESCRIPTOR,
PERSONAL_NOTES_DESCRIPTOR,
TEXT_CHANNEL_DESCRIPTOR,
VOICE_CHANNEL_DESCRIPTOR,
@@ -69,6 +70,9 @@ export const useChannelHeaderData = (channel?: Channel): ChannelHeaderData => {
if (channel.type === ChannelTypes.GUILD_TEXT) {
return i18n._(TEXT_CHANNEL_DESCRIPTOR);
}
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT) {
return i18n._(ANNOUNCEMENT_CHANNEL_DESCRIPTOR);
}
if (channel.type === ChannelTypes.GUILD_VOICE) {
return i18n._(VOICE_CHANNEL_DESCRIPTOR);
}
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {openChannelFollowModal} from '@app/features/channel/components/modals/ChannelFollowModal';
import type {Channel} from '@app/features/channel/models/Channel';
import {
canFollowAnnouncementChannel,
FOLLOW_CHANNEL_DESCRIPTOR,
FOLLOW_DESCRIPTOR,
} from '@app/features/channel/utils/ChannelFollowUtils';
import {Button} from '@app/features/ui/button/Button';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
export const ChannelFollowButton = observer(({channel, className}: {channel: Channel; className?: string}) => {
const {i18n} = useLingui();
if (!canFollowAnnouncementChannel(channel)) return null;
return (
<Button
variant="secondary"
superCompact
fitContent
className={className}
aria-label={i18n._(FOLLOW_CHANNEL_DESCRIPTOR)}
onClick={() => openChannelFollowModal(channel.id)}
data-flx="channel.channel-header-components.channel-follow-button.button.open-follow-modal"
>
{i18n._(FOLLOW_DESCRIPTOR)}
</Button>
);
});
@@ -2,6 +2,7 @@
import {
AccountTooNewBarrier,
AnnouncementFollowBarrier,
NoPhoneNumberBarrier,
NotMemberLongEnoughBarrier,
SendMessageDisabledBarrier,
@@ -346,6 +347,23 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
/>
) : null;
const passesVerification = channel.isPrivate() || GuildVerification.canAccessGuild(channel.guildId || '');
const renderComposer = (inputSuppressed: boolean) => {
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && !Permission.can(Permissions.SEND_MESSAGES, channel)) {
return (
<AnnouncementFollowBarrier
channelId={channel.id}
data-flx="channel.channel-view.guild-channel-view.render-composer.announcement-follow-barrier"
/>
);
}
return (
<ChannelTextarea
channel={channel}
inputSuppressed={inputSuppressed}
data-flx="channel.channel-view.guild-channel-view.render-composer.channel-textarea"
/>
);
};
const renderChatArea = (inputSuppressed = false) => {
if (DeveloperOptions.mockVerificationBarrier !== 'none' && !channel.isPrivate()) {
switch (DeveloperOptions.mockVerificationBarrier) {
@@ -381,11 +399,7 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
);
default:
return passesVerification ? (
<ChannelTextarea
channel={channel}
inputSuppressed={inputSuppressed}
data-flx="channel.channel-view.guild-channel-view.render-chat-area.channel-textarea"
/>
renderComposer(inputSuppressed)
) : (
<VerificationBarrier
channel={channel}
@@ -395,11 +409,7 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
}
}
return passesVerification ? (
<ChannelTextarea
channel={channel}
inputSuppressed={inputSuppressed}
data-flx="channel.channel-view.guild-channel-view.render-chat-area.channel-textarea--2"
/>
renderComposer(inputSuppressed)
) : (
<VerificationBarrier
channel={channel}
@@ -0,0 +1,178 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.banner {
position: relative;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
height: 5.5rem;
overflow: hidden;
background-color: var(--background-tertiary);
border-bottom: 0.0625rem solid var(--background-header-secondary);
}
.patternImage {
position: absolute;
inset: 0;
background-size: 16.25rem 16.25rem;
background-repeat: repeat;
opacity: 0.06;
filter: invert(1);
background-color: transparent;
pointer-events: none;
}
.bannerRow {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.sourceGuildIcon {
box-shadow: 0 0 0 0.25rem var(--background-tertiary);
}
.sourceChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
max-width: 14rem;
height: 2.5rem;
padding: 0 var(--spacing-3);
border-radius: var(--radius-md);
background-color: var(--background-modifier-accent);
color: var(--text-primary);
}
.sourceChipIcon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.sourceChipName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1rem;
font-weight: 600;
line-height: 1.25rem;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-top: var(--spacing-5);
padding-bottom: var(--spacing-4);
}
.intro {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
text-align: center;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
color: var(--text-primary);
}
.description {
margin: 0;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.hint {
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-primary-muted);
}
.emptyState {
border-radius: var(--radius-lg);
background-color: var(--background-tertiary);
padding: var(--spacing-4);
text-align: center;
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.guildOption {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
min-height: 2rem;
}
.guildAvatar,
.guildAvatarPlaceholder {
height: 1.5rem;
width: 1.5rem;
border-radius: 62.4375rem;
flex: 0 0 1.5rem;
}
.guildAvatar {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.guildAvatarPlaceholder {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--background-modifier-accent);
color: var(--text-primary);
font-weight: 700;
font-size: 0.75rem;
}
.optionLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.channelOption {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
min-width: 0;
min-height: 2rem;
}
.channelIcon {
height: 1.125rem;
width: 1.125rem;
flex-shrink: 0;
color: var(--text-tertiary);
}
.categoryName {
margin-left: auto;
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
@@ -0,0 +1,267 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {
createGuildComboboxRenderers,
type GuildComboboxOption,
} from '@app/features/app/components/dialogs/shared/GuildComboboxRenderers';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import * as ChannelFollowCommands from '@app/features/channel/commands/ChannelFollowCommands';
import styles from '@app/features/channel/components/modals/ChannelFollowModal.module.css';
import {openChannelFollowSuccessModal} from '@app/features/channel/components/modals/ChannelFollowSuccessModal';
import Channels from '@app/features/channel/state/Channels';
import {
type ChannelFollowTarget,
FOLLOW_DESCRIPTOR,
getChannelFollowSourceRestriction,
getFollowTargetChannels,
getFollowTargetGuilds,
} from '@app/features/channel/utils/ChannelFollowUtils';
import {UNKNOWN_CHANNEL_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors';
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 {CANCEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {Form} from '@app/features/ui/components/form/Form';
import {Combobox, type ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import foodPatternUrl from '@app/media/images/i-like-food.svg';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useMemo} from 'react';
import {Controller, useForm} from 'react-hook-form';
const FOLLOW_CHANNEL_TITLE_DESCRIPTOR = msg({
message: 'Follow this channel',
comment:
'Title of the dialog that follows an announcement channel, so its published messages are copied into a channel of a community the user picks.',
});
const SEND_TO_LABEL_DESCRIPTOR = msg({
message: 'Community',
comment: 'Field label in the follow announcement channel dialog. The user picks the community that receives updates.',
});
const SELECT_CHANNEL_LABEL_DESCRIPTOR = msg({
message: 'Channel',
comment:
'Field label in the follow announcement channel dialog. The user picks the text channel that receives updates.',
});
const SELECT_A_COMMUNITY_DESCRIPTOR = msg({
message: 'Select a community',
comment: 'Placeholder of the community picker in the follow announcement channel dialog.',
});
const SELECT_A_CHANNEL_DESCRIPTOR = msg({
message: 'Select a channel',
comment: 'Placeholder of the channel picker in the follow announcement channel dialog.',
});
interface FormInputs {
guildId: string;
channelId: string;
}
interface ChannelFollowTargetOption extends ComboboxOption {
target: ChannelFollowTarget;
}
export const ChannelFollowModal = observer(({channelId}: {channelId: string}) => {
const {i18n} = useLingui();
const source = Channels.getChannel(channelId);
const sourceGuild = source?.guildId ? Guilds.getGuild(source.guildId) : undefined;
const restriction = source ? getChannelFollowSourceRestriction(source) : null;
const guilds = getFollowTargetGuilds(restriction);
const form = useForm<FormInputs>({defaultValues: {guildId: '', channelId: ''}});
const selectedGuildId = form.watch('guildId');
const selectedChannelId = form.watch('channelId');
const targets = selectedGuildId ? getFollowTargetChannels(selectedGuildId, restriction) : [];
const guildOptions: Array<GuildComboboxOption> = guilds.map((guild) => ({
value: guild.id,
label: guild.name,
iconUrl: guild.icon ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : null,
}));
const channelOptions: Array<ChannelFollowTargetOption> = targets.map((target) => ({
value: target.channel.id,
label: target.channel.name ?? i18n._(UNKNOWN_CHANNEL_DESCRIPTOR),
target,
}));
const guildRenderers = useMemo(
() =>
createGuildComboboxRenderers<GuildComboboxOption>({
styles: {
optionRow: styles.guildOption,
avatar: styles.guildAvatar,
avatarPlaceholder: styles.guildAvatarPlaceholder,
label: styles.optionLabel,
},
}),
[],
);
const renderChannelOption = useCallback(
(option: ChannelFollowTargetOption) => (
<div className={styles.channelOption} data-flx="channel.channel-follow-modal.channel-option">
{ChannelUtils.getIcon(option.target.channel, {className: styles.channelIcon})}
<span className={styles.optionLabel} data-flx="channel.channel-follow-modal.channel-option.label">
{option.label}
</span>
{option.target.categoryName && (
<span className={styles.categoryName} data-flx="channel.channel-follow-modal.channel-option.category">
{option.target.categoryName}
</span>
)}
</div>
),
[],
);
const onSubmit = async (data: FormInputs) => {
if (!data.channelId) return;
await ChannelFollowCommands.followChannel(channelId, data.channelId);
ModalCommands.pop();
openChannelFollowSuccessModal({sourceChannelId: channelId, targetChannelId: data.channelId});
};
const {handleSubmit} = useFormSubmit({form, onSubmit, defaultErrorField: 'channelId'});
const hasGuilds = guilds.length > 0;
return (
<Modal.Root size="small" centered data-flx="channel.channel-follow-modal.modal-root">
<Modal.ScreenReaderLabel
text={i18n._(FOLLOW_CHANNEL_TITLE_DESCRIPTOR)}
data-flx="channel.channel-follow-modal.modal-screen-reader-label"
/>
<Form form={form} onSubmit={handleSubmit} data-flx="channel.channel-follow-modal.form.submit">
<div className={styles.banner} aria-hidden data-flx="channel.channel-follow-modal.banner">
<div
className={styles.patternImage}
style={{backgroundImage: `url(${foodPatternUrl})`}}
data-flx="channel.channel-follow-modal.pattern-image"
/>
<div className={styles.bannerRow} data-flx="channel.channel-follow-modal.banner-row">
{sourceGuild && (
<GuildIcon
id={sourceGuild.id}
name={sourceGuild.name}
icon={sourceGuild.icon}
sizePx={48}
className={styles.sourceGuildIcon}
containerProps={{'data-flx': 'channel.channel-follow-modal.source-guild-icon'}}
data-flx="channel.channel-follow-modal.guild-icon"
/>
)}
{source && (
<div className={styles.sourceChip} data-flx="channel.channel-follow-modal.source-chip">
{ChannelUtils.getIcon(source, {className: styles.sourceChipIcon})}
<span className={styles.sourceChipName} data-flx="channel.channel-follow-modal.source-chip-name">
{source.name ?? i18n._(UNKNOWN_CHANNEL_DESCRIPTOR)}
</span>
</div>
)}
</div>
</div>
<Modal.Content contentClassName={styles.content} data-flx="channel.channel-follow-modal.modal-content">
<div className={styles.intro} data-flx="channel.channel-follow-modal.intro">
<h2 className={styles.title} data-flx="channel.channel-follow-modal.title">
{i18n._(FOLLOW_CHANNEL_TITLE_DESCRIPTOR)}
</h2>
<p className={styles.description} data-flx="channel.channel-follow-modal.description">
<Trans comment="Subtitle of the follow announcement channel dialog. Community settings and Webhooks are the names of the settings page and its tab where followed channels are listed.">
Choose where its published messages should go. You can unfollow any time in Community settings →
Webhooks.
</Trans>
</p>
</div>
{restriction === 'age_restricted' && (
<WarningAlert data-flx="channel.channel-follow-modal.age-restricted-warning">
<Trans comment="Warning in the follow announcement channel dialog when the followed channel is age-restricted.">
This is an age-restricted channel. Updates can only go to age-restricted channels.
</Trans>
</WarningAlert>
)}
{restriction === 'content_warning' && (
<WarningAlert data-flx="channel.channel-follow-modal.content-warning-warning">
<Trans comment="Warning in the follow announcement channel dialog when the followed channel has a content warning.">
This channel has a content warning. Updates can only go to channels with a content warning or an age
restriction.
</Trans>
</WarningAlert>
)}
{hasGuilds ? (
<>
<Controller
name="guildId"
control={form.control}
render={({field}) => (
<Combobox<string, false, GuildComboboxOption>
label={i18n._(SEND_TO_LABEL_DESCRIPTOR)}
value={field.value}
options={guildOptions}
onChange={(value) => {
field.onChange(value);
form.setValue('channelId', '');
form.clearErrors('channelId');
}}
placeholder={i18n._(SELECT_A_COMMUNITY_DESCRIPTOR)}
renderOption={guildRenderers.renderOption}
renderValue={guildRenderers.renderValue}
data-flx="channel.channel-follow-modal.guild-select"
/>
)}
data-flx="channel.channel-follow-modal.guild-controller"
/>
<Controller
name="channelId"
control={form.control}
render={({field, fieldState}) => (
<Combobox<string, false, ChannelFollowTargetOption>
label={i18n._(SELECT_CHANNEL_LABEL_DESCRIPTOR)}
value={field.value}
options={channelOptions}
onChange={(value) => field.onChange(value)}
placeholder={i18n._(SELECT_A_CHANNEL_DESCRIPTOR)}
disabled={!selectedGuildId}
error={fieldState.error?.message}
renderOption={renderChannelOption}
renderValue={(option) => (option ? renderChannelOption(option) : null)}
data-flx="channel.channel-follow-modal.channel-select"
/>
)}
data-flx="channel.channel-follow-modal.channel-controller"
/>
<p className={styles.hint} data-flx="channel.channel-follow-modal.hint">
<Trans comment="Footnote under the pickers in the follow announcement channel dialog.">
Communities and channels where you can't manage webhooks are hidden.
</Trans>
</p>
</>
) : (
<div className={styles.emptyState} data-flx="channel.channel-follow-modal.empty-state">
<Trans comment="Empty state of the follow announcement channel dialog when the user cannot manage webhooks anywhere.">
You can't manage webhooks in any community. Ask an admin to follow this channel.
</Trans>
</div>
)}
</Modal.Content>
<Modal.Footer data-flx="channel.channel-follow-modal.modal-footer">
<Button onClick={ModalCommands.pop} variant="secondary" data-flx="channel.channel-follow-modal.button.cancel">
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
<Button
type="submit"
disabled={!hasGuilds || !selectedGuildId || !selectedChannelId}
submitting={form.formState.isSubmitting}
data-flx="channel.channel-follow-modal.button.submit"
>
{i18n._(FOLLOW_DESCRIPTOR)}
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});
export function openChannelFollowModal(channelId: string): void {
ModalCommands.push(
modal(() => <ChannelFollowModal channelId={channelId} data-flx="channel.channel-follow-modal.open" />),
);
}
@@ -0,0 +1,90 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.hero {
position: relative;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
height: 8.5rem;
overflow: hidden;
background-color: var(--brand-primary);
}
.patternImage {
position: absolute;
inset: 0;
background-size: 16.25rem 16.25rem;
background-repeat: repeat;
opacity: 0.06;
filter: invert(1);
background-color: transparent;
pointer-events: none;
}
.composition {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.sourceIconWrap {
position: relative;
display: inline-flex;
}
.guildIcon {
box-shadow: 0 0 0 0.25rem var(--background-secondary);
}
.announcementBadge {
position: absolute;
right: -0.375rem;
bottom: -0.375rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 624.9375rem;
background-color: var(--background-tertiary);
box-shadow: 0 0 0 0.25rem var(--background-secondary);
color: var(--text-primary);
}
.announcementBadgeIcon {
width: 0.875rem;
height: 0.875rem;
}
.arrow {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
color: var(--text-on-brand-primary);
}
.body {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-6) var(--spacing-6) var(--spacing-5);
text-align: center;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
color: var(--text-primary);
}
.description {
margin: 0 0 var(--spacing-3);
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import styles from '@app/features/channel/components/modals/ChannelFollowSuccessModal.module.css';
import Channels from '@app/features/channel/state/Channels';
import {UNKNOWN_CHANNEL_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import Guilds from '@app/features/guild/state/Guilds';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {AnnouncementChannelIcon} from '@app/features/ui/components/icons/AnnouncementChannelIcon';
import foodPatternUrl from '@app/media/images/i-like-food.svg';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowRightIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
const FOLLOW_SUCCESS_TITLE_DESCRIPTOR = msg({
message: 'Updates are on their way!',
comment: 'Heading of the dialog shown after the user follows an announcement channel.',
});
const FOLLOW_SUCCESS_BODY_DESCRIPTOR = msg({
message: 'Messages published in #{sourceName} will now appear in #{targetName}.',
comment:
'Body of the dialog shown after the user follows an announcement channel. sourceName is the followed announcement channel and targetName the channel that receives its updates, both without the leading #.',
});
const GOT_IT_DESCRIPTOR = msg({
message: 'Got it!',
comment: 'Button that closes the dialog shown after the user follows an announcement channel.',
});
interface ChannelFollowSuccessModalProps {
sourceChannelId: string;
targetChannelId: string;
}
export const ChannelFollowSuccessModal = observer(
({sourceChannelId, targetChannelId}: ChannelFollowSuccessModalProps) => {
const {i18n} = useLingui();
const source = Channels.getChannel(sourceChannelId);
const target = Channels.getChannel(targetChannelId);
const sourceGuild = source?.guildId ? Guilds.getGuild(source.guildId) : undefined;
const targetGuild = target?.guildId ? Guilds.getGuild(target.guildId) : undefined;
const title = i18n._(FOLLOW_SUCCESS_TITLE_DESCRIPTOR);
return (
<Modal.Root size="small" centered data-flx="channel.channel-follow-success-modal.modal-root">
<Modal.ScreenReaderLabel
text={title}
data-flx="channel.channel-follow-success-modal.modal-screen-reader-label"
/>
<div className={styles.hero} aria-hidden data-flx="channel.channel-follow-success-modal.hero">
<div
className={styles.patternImage}
style={{backgroundImage: `url(${foodPatternUrl})`}}
data-flx="channel.channel-follow-success-modal.pattern-image"
/>
<div className={styles.composition} data-flx="channel.channel-follow-success-modal.composition">
{sourceGuild && (
<div className={styles.sourceIconWrap} data-flx="channel.channel-follow-success-modal.source-icon-wrap">
<GuildIcon
id={sourceGuild.id}
name={sourceGuild.name}
icon={sourceGuild.icon}
sizePx={56}
className={styles.guildIcon}
containerProps={{'data-flx': 'channel.channel-follow-success-modal.source-guild-icon'}}
data-flx="channel.channel-follow-success-modal.source-guild-icon"
/>
<span
className={styles.announcementBadge}
data-flx="channel.channel-follow-success-modal.announcement-badge"
>
<AnnouncementChannelIcon
className={styles.announcementBadgeIcon}
data-flx="channel.channel-follow-success-modal.announcement-badge-icon"
/>
</span>
</div>
)}
<ArrowRightIcon
weight="bold"
className={styles.arrow}
data-flx="channel.channel-follow-success-modal.arrow-right-icon"
/>
{targetGuild && (
<GuildIcon
id={targetGuild.id}
name={targetGuild.name}
icon={targetGuild.icon}
sizePx={56}
className={styles.guildIcon}
containerProps={{'data-flx': 'channel.channel-follow-success-modal.target-guild-icon'}}
data-flx="channel.channel-follow-success-modal.target-guild-icon"
/>
)}
</div>
</div>
<div className={styles.body} data-flx="channel.channel-follow-success-modal.body">
<h2 className={styles.title} data-flx="channel.channel-follow-success-modal.title">
{title}
</h2>
<p className={styles.description} data-flx="channel.channel-follow-success-modal.description">
{i18n._(FOLLOW_SUCCESS_BODY_DESCRIPTOR, {
sourceName: source?.name ?? i18n._(UNKNOWN_CHANNEL_DESCRIPTOR),
targetName: target?.name ?? i18n._(UNKNOWN_CHANNEL_DESCRIPTOR),
})}
</p>
<Button onClick={ModalCommands.pop} data-flx="channel.channel-follow-success-modal.button.got-it">
{i18n._(GOT_IT_DESCRIPTOR)}
</Button>
</div>
</Modal.Root>
);
},
);
export function openChannelFollowSuccessModal(props: ChannelFollowSuccessModalProps): void {
ModalCommands.push(
modal(() => <ChannelFollowSuccessModal {...props} data-flx="channel.channel-follow-success-modal.open" />),
);
}
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands';
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {SettingsSection} from '@app/features/app/components/dialogs/shared/SettingsSection';
import {EXAMPLE_GENERAL_CHANNEL_NAME, EXAMPLE_URL} from '@app/features/app/config/I18nDisplayConstants';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
@@ -27,15 +29,27 @@ import {
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/VoiceSettings';
import Channels from '@app/features/channel/state/Channels';
import Guilds from '@app/features/guild/state/Guilds';
import {
ANNOUNCEMENT_CHANNEL_DESCRIPTOR,
TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Permission from '@app/features/permissions/state/Permission';
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import * as UnsavedChangesCommands from '@app/features/ui/commands/UnsavedChangesCommands';
import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {useRemoteFormReset} from '@app/lib/forms/RemoteFormReset';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES,
ChannelTypes,
GUILD_TEXT_BASED_CHANNEL_TYPES,
Permissions,
} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants';
import {msg} from '@lingui/core/macro';
@@ -43,7 +57,7 @@ import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useForm} from 'react-hook-form';
import {Controller, useForm} from 'react-hook-form';
const CHANNEL_TOPIC_IS_TOO_LONG_DESCRIPTOR = msg({
message: 'Channel topic is too long.',
@@ -74,6 +88,40 @@ const URL_DESCRIPTOR = msg({
comment:
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
});
const ANNOUNCEMENT_CHANNEL_SWITCH_DESCRIPTION_DESCRIPTOR = msg({
message: 'Lets other communities follow this channel and get copies of what you publish.',
comment:
'Description under the Announcement channel switch in channel settings. Turning it on converts a text channel into an announcement channel. Publishing a message sends a copy of it to every channel in other communities that follows this one.',
});
const STOP_BEING_AN_ANNOUNCEMENT_CHANNEL_DESCRIPTOR = msg({
message: 'Stop being an announcement channel?',
comment:
'Title of the confirmation shown when a member turns off the Announcement channel switch in channel settings, which converts the channel back into a normal text channel.',
});
const CONVERT_TO_TEXT_CHANNEL_FOLLOWERS_DESCRIPTOR = msg({
message:
'{count, plural, one {# channel follows} other {# channels follow}} this channel. Converting it to a text channel removes those follows.',
comment:
'Body of the confirmation shown before an announcement channel is converted back into a text channel. {count} is the number of channels, in this or other communities, that follow it and get copies of its published messages. Those channels stop getting copies after the conversion.',
});
const CONVERT_TO_TEXT_CHANNEL_UNKNOWN_FOLLOWERS_DESCRIPTOR = msg({
message: 'Converting this channel to a text channel removes every channel that follows it.',
comment:
'Body of the confirmation shown before an announcement channel is converted back into a text channel, used when the number of following channels could not be loaded. Channels that follow it get copies of its published messages and stop getting them after the conversion.',
});
const CONVERT_DESCRIPTOR = msg({
message: 'Convert',
comment: 'Button in the confirmation that converts an announcement channel back into a text channel. Keep it short.',
});
const COULD_NOT_CONVERT_CHANNEL_DESCRIPTOR = msg({
message: "Couldn't convert this channel",
comment:
'Title of the error dialog shown when converting a channel between a text channel and an announcement channel failed.',
});
const COULD_NOT_SAVE_CHANNEL_DESCRIPTOR = msg({
message: "Couldn't save channel settings",
comment: 'Title of the error dialog shown when saving the channel overview settings failed.',
});
const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) => {
const {i18n} = useLingui();
const channel = Channels.getChannel(channelId);
@@ -95,6 +143,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
nsfw_override: null,
content_warning_level: ContentWarningLevel.INHERIT,
content_warning_text: '',
announcement: false,
bitrate: BITRATE_KBPS_DEFAULT,
user_limit: 0,
voice_connection_limit: VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
@@ -110,6 +159,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
nsfw_override: channel.nsfwOverride,
content_warning_level: channel.contentWarningLevel ?? ContentWarningLevel.INHERIT,
content_warning_text: channel.contentWarningText ?? '',
announcement: channel.type === ChannelTypes.GUILD_ANNOUNCEMENT,
bitrate: Math.min(channel.bitrate ? Math.round(channel.bitrate / 1000) : BITRATE_KBPS_DEFAULT, maxBitrateKbps),
user_limit: channel.userLimit ?? 0,
voice_connection_limit: channel.voiceConnectionLimit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
@@ -193,6 +243,9 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
} else if (channel.type === ChannelTypes.GUILD_LINK) {
updateData.url = data.url;
}
if (ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES.has(channel.type) && dirty.announcement) {
updateData.type = data.announcement ? ChannelTypes.GUILD_ANNOUNCEMENT : ChannelTypes.GUILD_TEXT;
}
if (channel.guildId) {
if (dirty.nsfw_override) updateData.nsfw_override = data.nsfw_override;
if (dirty.content_warning_level) updateData.content_warning_level = data.content_warning_level;
@@ -209,25 +262,78 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
ToastCommands.createToast({type: 'success', children: <Trans>Channel updated</Trans>});
return;
}
await ChannelCommands.update(channel.id, updateData);
const currentValues = form.getValues();
commitRemoteValues({
name: data.name,
topic: data.topic ?? '',
url: data.url ?? '',
slowmode: data.slowmode ?? currentValues.slowmode ?? 0,
nsfw_override: data.nsfw_override,
content_warning_level: data.content_warning_level,
content_warning_text: data.content_warning_text ?? '',
bitrate: Math.min(data.bitrate ?? currentValues.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps),
user_limit: data.user_limit ?? currentValues.user_limit ?? 0,
voice_connection_limit:
data.voice_connection_limit ?? currentValues.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
rtc_region: data.rtc_region ?? currentValues.rtc_region ?? null,
});
ToastCommands.createToast({type: 'success', children: <Trans>Channel updated</Trans>});
const persist = async () => {
try {
await ChannelCommands.update(channel.id, updateData);
} catch (error) {
if (failureCode(error) === APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS) {
showGenericErrorModal({
title: i18n._(COULD_NOT_CONVERT_CHANNEL_DESCRIPTOR),
message: failureMessage(error) ?? i18n._(TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR),
dataFlx: 'channel.channel-tabs.channel-overview-tab.channel-has-followed-channels.generic-error-modal',
});
return;
}
throw error;
}
const currentValues = form.getValues();
commitRemoteValues({
name: data.name,
topic: data.topic ?? '',
url: data.url ?? '',
slowmode: data.slowmode ?? currentValues.slowmode ?? 0,
announcement: data.announcement ?? currentValues.announcement ?? false,
nsfw_override: data.nsfw_override,
content_warning_level: data.content_warning_level,
content_warning_text: data.content_warning_text ?? '',
bitrate: Math.min(data.bitrate ?? currentValues.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps),
user_limit: data.user_limit ?? currentValues.user_limit ?? 0,
voice_connection_limit:
data.voice_connection_limit ??
currentValues.voice_connection_limit ??
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
rtc_region: data.rtc_region ?? currentValues.rtc_region ?? null,
});
ToastCommands.createToast({type: 'success', children: <Trans>Channel updated</Trans>});
};
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && updateData.type === ChannelTypes.GUILD_TEXT) {
const followerCount = await ChannelCommands.fetchFollowerStats(channel.id).then(
(stats) => stats.channel_count,
() => null,
);
if (followerCount !== 0) {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(STOP_BEING_AN_ANNOUNCEMENT_CHANNEL_DESCRIPTOR)}
description={
followerCount === null
? i18n._(CONVERT_TO_TEXT_CHANNEL_UNKNOWN_FOLLOWERS_DESCRIPTOR)
: i18n._(CONVERT_TO_TEXT_CHANNEL_FOLLOWERS_DESCRIPTOR, {count: followerCount})
}
primaryText={i18n._(CONVERT_DESCRIPTOR)}
primaryVariant="danger"
onPrimary={async () => {
try {
await persist();
} catch (error) {
showGenericErrorModal({
title: i18n._(COULD_NOT_SAVE_CHANNEL_DESCRIPTOR),
message: failureMessage(error) ?? i18n._(TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR),
dataFlx: 'channel.channel-tabs.channel-overview-tab.convert-to-text.generic-error-modal',
});
}
}}
data-flx="channel.channel-tabs.channel-overview-tab.convert-to-text.confirm-modal"
/>
)),
);
return;
}
}
await persist();
},
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues, maxBitrateKbps],
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues, maxBitrateKbps, i18n],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
@@ -255,7 +361,8 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
};
}, []);
if (!channel) return null;
const isTextChannel = channel.type === ChannelTypes.GUILD_TEXT;
const isTextChannel = channel.type === ChannelTypes.GUILD_TEXT || channel.type === ChannelTypes.GUILD_ANNOUNCEMENT;
const isAnnouncementConvertible = ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES.has(channel.type);
const isGuildVoiceChannel = channel.type === ChannelTypes.GUILD_VOICE;
const isMessageableGuildChannel = GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type);
const isCategory = channel.type === ChannelTypes.GUILD_CATEGORY;
@@ -307,6 +414,22 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
data-flx="channel.channel-tabs.channel-overview-tab.channel-overview-topic-editor"
/>
<SlowmodeControl form={form} data-flx="channel.channel-tabs.channel-overview-tab.slowmode-control" />
{isAnnouncementConvertible && (
<Controller
name="announcement"
control={form.control}
render={({field}) => (
<Switch
label={i18n._(ANNOUNCEMENT_CHANNEL_DESCRIPTOR)}
description={i18n._(ANNOUNCEMENT_CHANNEL_SWITCH_DESCRIPTION_DESCRIPTOR)}
value={field.value ?? false}
onChange={field.onChange}
data-flx="channel.channel-tabs.channel-overview-tab.announcement-switch.change"
/>
)}
data-flx="channel.channel-tabs.channel-overview-tab.announcement-controller"
/>
)}
</div>
)}
{showVoiceSection && (
@@ -50,3 +50,22 @@
flex-direction: column;
gap: 0.75rem;
}
.followedSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.5rem;
}
.sectionTitle {
font-weight: 600;
font-size: 1rem;
line-height: 1.5rem;
}
.sectionDescription {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
@@ -16,6 +16,7 @@ import {Button} from '@app/features/ui/button/Button';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {Spinner} from '@app/features/ui/components/Spinner';
import * as WebhookCommands from '@app/features/webhook/commands/WebhookCommands';
import {FollowedChannelListItem} from '@app/features/webhook/components/FollowedChannelListItem';
import {WebhookListItem} from '@app/features/webhook/components/WebhookListItem';
import {useWebhookUpdates} from '@app/features/webhook/hooks/useWebhookUpdates';
import type {Webhook} from '@app/features/webhook/models/Webhook';
@@ -65,6 +66,8 @@ const ChannelWebhooksTab: React.FC<{channelId: string}> = observer(({channelId})
.map((ch) => ({id: ch.id, label: ch.name ?? i18n._(UNKNOWN_CHANNEL_DESCRIPTOR)})),
[guildChannels, i18n.locale],
);
const incomingWebhooks = useMemo(() => (webhooks ?? []).filter((webhook) => !webhook.isChannelFollower), [webhooks]);
const followerWebhooks = useMemo(() => (webhooks ?? []).filter((webhook) => webhook.isChannelFollower), [webhooks]);
const refreshWebhooks = useCallback(async () => {
if (!guildId) return;
try {
@@ -164,9 +167,9 @@ const ChannelWebhooksTab: React.FC<{channelId: string}> = observer(({channelId})
data-flx="channel.channel-tabs.channel-webhooks-tab.status-slate"
/>
)}
{fetchStatus === 'success' && webhooks && webhooks.length > 0 && (
{fetchStatus === 'success' && incomingWebhooks.length > 0 && (
<div className={styles.webhooksList} data-flx="channel.channel-tabs.channel-webhooks-tab.webhooks-list">
{webhooks.map((webhook: Webhook) => (
{incomingWebhooks.map((webhook: Webhook) => (
<WebhookListItem
key={webhook.id}
webhook={webhook}
@@ -182,7 +185,7 @@ const ChannelWebhooksTab: React.FC<{channelId: string}> = observer(({channelId})
))}
</div>
)}
{fetchStatus === 'success' && (!webhooks || webhooks.length === 0) && (
{fetchStatus === 'success' && incomingWebhooks.length === 0 && (
<StatusSlate
Icon={RobotIcon}
title={<Trans>No webhooks</Trans>}
@@ -203,10 +206,45 @@ const ChannelWebhooksTab: React.FC<{channelId: string}> = observer(({channelId})
]
: undefined
}
fullHeight={true}
fullHeight={followerWebhooks.length === 0}
data-flx="channel.channel-tabs.channel-webhooks-tab.status-slate--2"
/>
)}
{fetchStatus === 'success' && followerWebhooks.length > 0 && (
<section
className={styles.followedSection}
data-flx="channel.channel-tabs.channel-webhooks-tab.followed-section"
>
<div data-flx="channel.channel-tabs.channel-webhooks-tab.followed-header">
<h3 className={styles.sectionTitle} data-flx="channel.channel-tabs.channel-webhooks-tab.followed-title">
<Trans comment="Section heading in the channel webhook settings that lists announcement channels this channel follows.">
Followed channels
</Trans>
</h3>
<p
className={styles.sectionDescription}
data-flx="channel.channel-tabs.channel-webhooks-tab.followed-description"
>
<Trans comment="Description of the followed channels section in the channel webhook settings.">
Messages published in these announcement channels are copied here.
</Trans>
</p>
</div>
<div className={styles.webhooksList} data-flx="channel.channel-tabs.channel-webhooks-tab.followed-list">
{followerWebhooks.map((webhook: Webhook) => (
<FollowedChannelListItem
key={webhook.id}
webhook={webhook}
onUpdate={handleUpdate}
isExpanded={expandedIds.has(webhook.id)}
onExpandedChange={(open) => setExpanded(webhook.id, open)}
formVersion={formVersion}
data-flx="channel.channel-tabs.channel-webhooks-tab.followed-channel-list-item"
/>
))}
</div>
</section>
)}
</div>
);
});
@@ -10,6 +10,7 @@ export interface FormInputs {
topic?: string;
url?: string;
slowmode?: number;
announcement?: boolean;
nsfw_override: boolean | null;
content_warning_level: number;
content_warning_text: string;
@@ -160,6 +160,10 @@ export class Channel {
return this.type === ChannelTypes.GUILD_TEXT;
}
isGuildAnnouncement(): boolean {
return this.type === ChannelTypes.GUILD_ANNOUNCEMENT;
}
isGuildVoice(): boolean {
return this.type === ChannelTypes.GUILD_VOICE;
}
@@ -184,6 +188,7 @@ export class Channel {
if (
this.guildId == null ||
(this.type !== ChannelTypes.GUILD_TEXT &&
this.type !== ChannelTypes.GUILD_ANNOUNCEMENT &&
this.type !== ChannelTypes.GUILD_VOICE &&
this.type !== ChannelTypes.GUILD_LINK)
) {
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {fetchCrosspostSource} from '@app/features/channel/commands/CrosspostSourceCommands';
import {HttpError} from '@app/features/platform/types/EndpointError';
import type {CrosspostSourceGuildResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas';
import {makeAutoObservable} from 'mobx';
const CARD_TTL_MS = 60_000;
export type CrosspostSourceCardState =
| {status: 'loading'}
| {status: 'ready'; card: CrosspostSourceGuildResponse; fetchedAt: number}
| {status: 'unavailable'; fetchedAt: number}
| {status: 'failed'; fetchedAt: number};
class CrosspostSourceCards {
entries: Map<string, CrosspostSourceCardState> = new Map();
private inflight = new Set<string>();
constructor() {
makeAutoObservable<CrosspostSourceCards, 'inflight'>(this, {inflight: false}, {autoBind: true});
}
get(sourceGuildId: string): CrosspostSourceCardState | null {
return this.entries.get(sourceGuildId) ?? null;
}
ensure(sourceGuildId: string, channelId: string, messageId: string): void {
if (this.inflight.has(sourceGuildId)) {
return;
}
const entry = this.entries.get(sourceGuildId);
if (entry != null && entry.status !== 'loading' && Date.now() - entry.fetchedAt < CARD_TTL_MS) {
return;
}
this.inflight.add(sourceGuildId);
if (entry == null) {
this.setEntry(sourceGuildId, {status: 'loading'});
}
void this.load(sourceGuildId, channelId, messageId);
}
private async load(sourceGuildId: string, channelId: string, messageId: string): Promise<void> {
try {
const response = await fetchCrosspostSource(channelId, messageId);
this.setEntry(sourceGuildId, {status: 'ready', card: response.guild, fetchedAt: Date.now()});
} catch (error) {
if (error instanceof HttpError && (error.status === 404 || error.status === 403)) {
this.setEntry(sourceGuildId, {status: 'unavailable', fetchedAt: Date.now()});
} else if (this.entries.get(sourceGuildId)?.status !== 'ready') {
this.setEntry(sourceGuildId, {status: 'failed', fetchedAt: 0});
}
} finally {
this.inflight.delete(sourceGuildId);
}
}
private setEntry(sourceGuildId: string, state: CrosspostSourceCardState): void {
this.entries = new Map(this.entries).set(sourceGuildId, state);
}
}
export default new CrosspostSourceCards();
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessagingMessages from '@app/features/messaging/state/MessagingMessages';
import {makeSyncedField} from '@app/features/user/state/SyncedField';
import {MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {AnnouncementPromptsStateSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb';
import {makeAutoObservable} from 'mobx';
function isPublishCandidate(message: Message): boolean {
return (
message.isCurrentUserAuthor() &&
message.state === MessageStates.SENT &&
message.type === MessageTypes.DEFAULT &&
!message.isCrosspostCopy
);
}
class PublishNudge {
hidePublishNudge = false;
dismissedMessageIds = new Set<string>();
constructor() {
makeAutoObservable(this, {shouldShow: false}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makeSyncedField(this, {
field: 'announcementPrompts',
schema: AnnouncementPromptsStateSchema,
persist: ['hidePublishNudge'],
toMessage: (s) => ({hidePublishNudge: s.hidePublishNudge}),
applyMessage: (s, m) => {
s.hidePublishNudge = m.hidePublishNudge;
},
});
}
dismiss(messageId: string): void {
this.dismissedMessageIds.add(messageId);
}
hideForever(): void {
this.hidePublishNudge = true;
}
shouldShow(message: Message, canCrosspost: boolean): boolean {
if (this.hidePublishNudge || !canCrosspost || message.isCrossposted) {
return false;
}
if (!isPublishCandidate(message) || this.dismissedMessageIds.has(message.id)) {
return false;
}
void MessagingMessages.version;
if (!MessagingMessages.hasNewestMessages(message.channelId)) {
return false;
}
const newestOwn = MessagingMessages.getCachedMessages(message.channelId)?.searchFromNewest(isPublishCandidate);
return newestOwn?.id === message.id;
}
}
export default new PublishNudge();
@@ -36,6 +36,11 @@ export const channelTypeOptions: Array<ChannelTypeOption> = [
name: 'Text Channel',
desc: 'Send messages, images, GIFs, and emoji',
},
{
value: ChannelTypes.GUILD_ANNOUNCEMENT,
name: 'Announcement Channel',
desc: 'Post updates that other communities can follow into their own channels',
},
{
value: ChannelTypes.GUILD_VOICE,
name: 'Voice Channel',
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import {
getEffectiveChannelContentWarning,
getEffectiveChannelMatureContent,
} from '@app/features/messaging/utils/ContentWarningUtils';
import Permission from '@app/features/permissions/state/Permission';
import {CHANNEL_FOLLOW_TARGET_TYPES, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import {msg} from '@lingui/core/macro';
export const FOLLOW_DESCRIPTOR = msg({
message: 'Follow',
comment:
'Button label that follows an announcement channel, so its published messages are copied into a channel the user picks.',
});
export const FOLLOW_CHANNEL_DESCRIPTOR = msg({
message: 'Follow channel',
comment:
'Context menu action on an announcement channel. It opens a dialog to pick a channel that will receive its published messages.',
});
export type ChannelFollowSourceRestriction = 'age_restricted' | 'content_warning' | null;
export interface ChannelFollowTarget {
channel: Channel;
categoryName: string | null;
}
const FOLLOW_TARGET_CHANNEL_PERMISSIONS = Permissions.MANAGE_WEBHOOKS | Permissions.VIEW_CHANNEL;
function guildOf(channel: Channel): Guild | null {
return channel.guildId ? (Guilds.getGuild(channel.guildId) ?? null) : null;
}
function hasContentWarning(channel: Channel): boolean {
return getEffectiveChannelContentWarning(channel, guildOf(channel)).level === ContentWarningLevel.CONTENT_WARNING;
}
function isAgeRestricted(channel: Channel): boolean {
return getEffectiveChannelMatureContent(channel, guildOf(channel));
}
export function getChannelFollowSourceRestriction(source: Channel): ChannelFollowSourceRestriction {
if (isAgeRestricted(source)) return 'age_restricted';
if (hasContentWarning(source)) return 'content_warning';
return null;
}
export function isAllowedFollowTarget(restriction: ChannelFollowSourceRestriction, target: Channel): boolean {
if (restriction === 'age_restricted') return isAgeRestricted(target);
if (restriction === 'content_warning') return isAgeRestricted(target) || hasContentWarning(target);
return true;
}
export function getFollowTargetGuilds(restriction: ChannelFollowSourceRestriction): Array<Guild> {
return Guilds.getGuilds()
.filter((guild) => getFollowTargetChannels(guild.id, restriction).length > 0)
.sort((a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}));
}
export function canFollowAnnouncementChannel(channel: Channel): boolean {
if (channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT) return false;
return Guilds.getGuilds().some((guild) => Permission.can(Permissions.MANAGE_WEBHOOKS, {guildId: guild.id}));
}
export function getFollowTargetChannels(
guildId: string,
restriction: ChannelFollowSourceRestriction,
): Array<ChannelFollowTarget> {
const result: Array<ChannelFollowTarget> = [];
for (const channel of Channels.getGuildChannels(guildId)) {
if (!CHANNEL_FOLLOW_TARGET_TYPES.has(channel.type)) continue;
if (!Permission.can(FOLLOW_TARGET_CHANNEL_PERMISSIONS, channel)) continue;
if (!isAllowedFollowTarget(restriction, channel)) continue;
const category = channel.parentId ? Channels.getChannel(channel.parentId) : null;
result.push({channel, categoryName: category?.name ?? null});
}
return result.sort((a, b) => {
if (a.categoryName === b.categoryName) return (a.channel.position ?? 0) - (b.channel.position ?? 0);
if (!a.categoryName) return -1;
if (!b.categoryName) return 1;
return a.categoryName.localeCompare(b.categoryName);
});
}
export function getFollowerWebhookTargetChannels(
guildId: string,
sourceChannelId: string | null,
currentChannelId: string,
): Array<Channel> {
const source = sourceChannelId ? Channels.getChannel(sourceChannelId) : null;
const restriction = source ? getChannelFollowSourceRestriction(source) : null;
const targets = getFollowTargetChannels(guildId, restriction).map((target) => target.channel);
if (!targets.some((channel) => channel.id === currentChannelId)) {
const current = Channels.getChannel(currentChannelId);
if (current) targets.unshift(current);
}
return targets;
}
@@ -6,11 +6,14 @@ import ChannelDisplayName from '@app/features/channel/state/ChannelDisplayName';
import {UNKNOWN_CHANNEL_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors';
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {compareChannelPosition} from '@app/features/messaging/utils/ChannelShared';
import {AnnouncementChannelIcon} from '@app/features/ui/components/icons/AnnouncementChannelIcon';
import {EncryptedVoiceChannelIcon} from '@app/features/ui/components/icons/EncryptedVoiceChannelIcon';
import {LinkChannelIcon} from '@app/features/ui/components/icons/LinkChannelIcon';
import {LockedAnnouncementChannelIcon} from '@app/features/ui/components/icons/LockedAnnouncementChannelIcon';
import {LockedLinkChannelIcon} from '@app/features/ui/components/icons/LockedLinkChannelIcon';
import {LockedTextChannelIcon} from '@app/features/ui/components/icons/LockedTextChannelIcon';
import {LockedVoiceChannelIcon} from '@app/features/ui/components/icons/LockedVoiceChannelIcon';
import {MatureAnnouncementChannelIcon} from '@app/features/ui/components/icons/MatureAnnouncementChannelIcon';
import {MatureLinkChannelIcon} from '@app/features/ui/components/icons/MatureLinkChannelIcon';
import {MatureTextChannelIcon} from '@app/features/ui/components/icons/MatureTextChannelIcon';
import {MatureVoiceChannelIcon} from '@app/features/ui/components/icons/MatureVoiceChannelIcon';
@@ -44,6 +47,16 @@ const LINK_DESCRIPTOR = msg({
message: 'Link',
comment: 'Channel type chip label for a linked external channel.',
});
const ANNOUNCEMENT_MATURE_DESCRIPTOR = msg({
message: 'Announcement (mature)',
comment:
'Channel type chip label for a mature-content announcement channel, whose messages can be published to channels that follow it.',
});
const ANNOUNCEMENT_DESCRIPTOR = msg({
message: 'Announcement',
comment:
'Channel type chip label for an announcement channel, whose messages can be published to channels that follow it.',
});
const TEXT_MATURE_DESCRIPTOR = msg({
message: 'Text (mature)',
comment: 'Channel type chip label for a mature-content text channel.',
@@ -77,6 +90,7 @@ function isChannelRoleRequired(channel: {
case ChannelTypes.GUILD_VOICE:
return (overwrite.deny & Permissions.CONNECT) === Permissions.CONNECT;
case ChannelTypes.GUILD_TEXT:
case ChannelTypes.GUILD_ANNOUNCEMENT:
case ChannelTypes.GUILD_LINK:
return (overwrite.deny & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL;
default:
@@ -103,6 +117,23 @@ export function getIcon(
return <LockedTextChannelIcon data-flx="channel.channel-utils.get-icon.locked-text-channel-icon" {...props} />;
return <TextChannelIcon data-flx="channel.channel-utils.get-icon.text-channel-icon" {...props} />;
}
case ChannelTypes.GUILD_ANNOUNCEMENT: {
if (channel.nsfw)
return (
<MatureAnnouncementChannelIcon
data-flx="channel.channel-utils.get-icon.mature-announcement-channel-icon"
{...props}
/>
);
if (roleRequired)
return (
<LockedAnnouncementChannelIcon
data-flx="channel.channel-utils.get-icon.locked-announcement-channel-icon"
{...props}
/>
);
return <AnnouncementChannelIcon data-flx="channel.channel-utils.get-icon.announcement-channel-icon" {...props} />;
}
case ChannelTypes.GUILD_VOICE: {
if (options?.e2eeEncrypted)
return (
@@ -147,6 +178,8 @@ export function getName(channel: Channel) {
return i18n._(CATEGORY_DESCRIPTOR);
case ChannelTypes.GUILD_LINK:
return channel.nsfw ? i18n._(LINK_MATURE_DESCRIPTOR) : i18n._(LINK_DESCRIPTOR);
case ChannelTypes.GUILD_ANNOUNCEMENT:
return channel.nsfw ? i18n._(ANNOUNCEMENT_MATURE_DESCRIPTOR) : i18n._(ANNOUNCEMENT_DESCRIPTOR);
default:
return channel.nsfw ? i18n._(TEXT_MATURE_DESCRIPTOR) : i18n._(TEXT_DESCRIPTOR);
}
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
function communityNameFromAuthor(name: string): string {
return name.replace(/\s#[^\s#]+$/, '').trim() || name;
}
export function getCrosspostSourceGuildId(message: Message): string | null {
return message.messageReference?.guild_id ?? null;
}
export function getCrosspostSourceDisplayName(message: Message): string {
const name = message.type === MessageTypes.CHANNEL_FOLLOW_ADD ? message.content : message.author.username;
return communityNameFromAuthor(name);
}