mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat: add announcement channels, publishing and following (#3090)
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
+30
@@ -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" />),
|
||||
);
|
||||
}
|
||||
+90
@@ -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" />),
|
||||
);
|
||||
}
|
||||
+144
-21
@@ -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 && (
|
||||
|
||||
+19
@@ -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);
|
||||
}
|
||||
+42
-4
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user