From ba59a131493ad18543f99935834e3b17a193ad50 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 7 Oct 2026 15:25:43 +0200 Subject: [PATCH] feat(threads): thread and forum UI on web (#3252) --- fluxer_app/index.html | 2 +- fluxer_app/src/app/Routes.ts | 2 + .../router/components/GuildChannelRouter.tsx | 28 +- .../src/app/router/routes/AppRoutes.tsx | 34 +- .../floating/UnreadChannelsContent.tsx | 4 + .../app/components/layout/ChannelItem.tsx | 3 +- .../components/layout/ChannelListContent.tsx | 27 + .../layout/dnd/ChannelReorderStateMachine.ts | 4 +- .../layout/utils/ChannelMoveOperation.ts | 5 +- .../layout/utils/ChannelOrganization.ts | 3 +- .../features/app/config/AuditLogConstants.ts | 39 +- .../src/features/app/constants/Endpoints.ts | 13 + .../app/hooks/useChannelHoverPreload.ts | 4 + .../handlers/defaultHandlers.ts | 2 + .../handlers/messageHandlers.ts | 9 +- .../OAuthAuthorizePageShared.ts | 2 + .../hooks/useBotGuilds.ts | 5 +- .../hooks/usePermissionSelection.ts | 38 +- .../state/useAuthorizeFlow.ts | 12 +- .../components/ChannelAttachmentArea.tsx | 2 +- .../components/ChannelHeader.module.css | 19 + .../channel/components/ChannelHeader.tsx | 155 +- .../channel/components/ChannelLayout.tsx | 5 + .../channel/components/ChannelMessages.tsx | 23 +- .../channel/components/ChannelTextarea.tsx | 4 +- .../channel/components/ChannelUserTag.tsx | 10 +- .../components/ChannelWelcomeSection.tsx | 4 + .../components/CompactMessageLayout.tsx | 8 + .../LexicalChannelTextareaContent.tsx | 339 +-- .../channel/components/MessageActionBar.tsx | 17 + .../channel/components/MessageActionMenu.tsx | 12 + .../channel/components/MessageActionUtils.tsx | 20 +- .../channel/components/MessageAttachments.tsx | 5 + .../channel/components/MessageAuthorInfo.tsx | 8 + .../channel/components/UserMessage.tsx | 15 + .../components/barriers/BarrierComponents.tsx | 2 +- .../ChannelDetailsBottomSheet.tsx | 9 +- .../channel_view/GuildChannelView.tsx | 51 +- .../embeds/attachments/AttachmentGridItem.tsx | 2 +- .../components/embeds/media/EmbedAudio.tsx | 2 +- .../embeds/media/MediaButtonUtils.ts | 5 +- .../components/modals/ChannelCreateModal.tsx | 5 +- .../channel_tabs/ChannelOverviewTab.tsx | 56 +- .../channel_tabs/ChannelPermissionsTab.tsx | 12 +- .../channel_overview_tab/SlowmodeControl.tsx | 8 +- .../channel_overview_tab/shared.ts | 2 + .../textarea/TextareaInput.module.css | 18 + .../components/textarea/TextareaPlusMenu.tsx | 13 + .../components/useChannelComposerPaste.tsx | 8 +- .../features/channel/events/ChannelDelete.ts | 29 +- .../src/features/channel/models/Channel.ts | 136 +- .../channel/state/ChannelFrecency.test.ts | 199 ++ .../features/channel/state/ChannelFrecency.ts | 118 +- .../src/features/channel/state/Channels.ts | 75 +- .../channel/utils/ChannelCreateModalUtils.ts | 15 +- .../utils/ChannelSettingsModalUtils.ts | 3 + .../features/channel/utils/ChannelUtils.tsx | 30 +- .../state/ExperimentAssignments.test.ts | 16 + .../features/forum/commands/ForumCommands.ts | 145 ++ .../forum/components/Forum.module.css | 828 ++++++++ .../forum/components/ForumChannelView.tsx | 255 +++ .../components/ForumPostActions.module.css | 13 + .../forum/components/ForumPostActions.tsx | 106 + .../forum/components/ForumPostCard.tsx | 297 +++ .../forum/components/ForumPostComposer.tsx | 314 +++ .../forum/components/ForumPostContextMenu.tsx | 103 + .../forum/components/ForumPostTagsModal.tsx | 59 + .../forum/components/ForumTagPicker.tsx | 124 ++ .../forum/components/ForumTagPill.tsx | 80 + .../forum/components/ForumToolbar.tsx | 333 +++ .../components/settings/ForumOverviewTab.tsx | 408 ++++ .../components/settings/ForumTagEditModal.tsx | 151 ++ .../components/settings/ForumTagSettings.tsx | 91 + .../src/features/forum/hooks/useForumPosts.ts | 78 + .../features/forum/state/ForumPosts.test.ts | 325 +++ .../src/features/forum/state/ForumPosts.ts | 600 ++++++ .../features/forum/state/ForumReadState.ts | 209 ++ .../forum/utils/ForumChannelTypeOptions.ts | 21 + .../features/forum/utils/ForumChannelUtils.ts | 81 + .../src/features/forum/utils/ForumErrors.ts | 17 + .../forum/utils/ForumMessageDescriptors.ts | 424 ++++ .../forum/utils/ForumNotificationUtils.ts | 25 + .../features/forum/utils/ForumPermissions.ts | 25 + .../src/features/forum/utils/ForumPostList.ts | 71 + .../forum/utils/MediaDownloadPolicy.ts | 14 + .../features/gateway/events/EventRouter.ts | 16 + .../features/gateway/events/GatewayReady.ts | 17 + .../gateway/transport/GatewayConnection.ts | 11 +- .../gateway/transport/GatewaySocket.ts | 14 + .../gateway/types/GatewayGuildTypes.ts | 2 + .../features/guild/commands/GuildCommands.ts | 2 + .../modals/guild_tabs/GuildAuditLogTab.tsx | 22 +- .../modals/guild_tabs/GuildRolesTab.tsx | 13 +- .../src/features/guild/events/GuildCreate.ts | 6 + .../src/features/guild/events/GuildDelete.ts | 2 + .../state/GuildMatureContentAgree.test.ts | 78 + .../guild/state/GuildMatureContentAgree.ts | 35 +- .../features/guild/state/GuildReadState.ts | 48 +- .../utils/guild_tabs/GuildAuditLogTabUtils.ts | 5 + .../audit_log/AuditLogChannelPresentation.ts | 33 +- .../audit_log/AuditLogForumPresentation.ts | 311 +++ .../guild_tabs/audit_log/AuditLogValues.ts | 8 +- .../src/features/i18n/locales/ar/messages.po | 1860 ++++++++++++---- .../locales/auto-i18n-reviewed-unchanged.json | 909 +++++++- .../src/features/i18n/locales/bg/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/cs/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/da/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/de/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/el/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/en-GB/messages.po | 1863 +++++++++++++---- .../features/i18n/locales/en-US/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/es-419/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/es-ES/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/fi/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/fr/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/he/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/hi/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/hr/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/hu/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/id/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/it/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/ja/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/ko/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/lt/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/nl/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/no/messages.po | 1858 ++++++++++++---- .../src/features/i18n/locales/pl/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/pt-BR/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/ro/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/ru/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/sv-SE/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/th/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/tr/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/uk/messages.po | 1860 ++++++++++++---- .../src/features/i18n/locales/vi/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/zh-CN/messages.po | 1860 ++++++++++++---- .../features/i18n/locales/zh-TW/messages.po | 1860 ++++++++++++---- .../features/inbox/commands/InboxCommands.tsx | 4 +- .../src/features/invite/utils/InviteUtils.ts | 3 +- .../composer/useLexicalAutocomplete.ts | 30 +- .../messaging/commands/MessageCommands.tsx | 8 + .../messaging/commands/ReactionCommands.tsx | 6 + .../markdown/renderers/MentionRenderer.tsx | 38 +- .../components/modals/MediaViewerModal.tsx | 10 +- .../components/popouts/InboxPopout.tsx | 9 +- .../messaging/events/MessageCreate.ts | 4 + .../messaging/events/MessageDelete.ts | 4 + .../messaging/events/MessageDeleteBulk.ts | 4 + .../messaging/events/MessageReactionAdd.ts | 9 + .../events/MessageReactionAddMany.ts | 9 + .../messaging/events/MessageReactionRemove.ts | 9 + .../events/MessageReactionRemoveAll.ts | 2 + .../events/MessageReactionRemoveEmoji.ts | 2 + .../messaging/events/MessageUpdate.ts | 6 + .../messaging/hooks/useMatureMedia.ts | 6 +- .../hooks/useTextareaExpressionHandlers.ts | 32 +- .../messaging/hooks/useTextareaSubmit.ts | 14 +- .../src/features/messaging/state/Favorites.ts | 2 + .../features/messaging/state/MessageQueue.tsx | 302 +-- .../MessageQueueAttachmentUpload.test.ts | 186 ++ .../messaging/state/MessagingMessages.ts | 9 + .../messaging/upload/AttachmentUploadPlan.ts | 266 +++ .../features/messaging/utils/ChannelShared.ts | 16 +- .../messaging/utils/FileUploadUtils.ts | 10 +- .../messaging/utils/MessageComponentUtils.tsx | 34 + .../messaging/utils/MessageNavigator.ts | 4 + .../features/messaging/utils/MessageUtils.ts | 4 +- .../messaging/utils/SystemMessageUtils.tsx | 7 + .../messaging/utils/markdown/Plaintext.ts | 5 + .../features/navigation/state/Navigation.ts | 6 + .../navigation/state/NavigationSideEffects.ts | 34 +- .../commands/RecentMentionCommands.ts | 5 + .../notification/state/MentionFeed.ts | 5 +- .../notification/state/NotificationAutoAck.ts | 18 + .../notification/state/UnreadChannels.ts | 2 + .../features/permissions/state/Permission.ts | 48 +- .../utils/PermissionLabelDescriptors.ts | 41 + .../permissions/utils/PermissionUtils.ts | 121 +- .../platform/transport/RestTransport.ts | 2 +- .../features/platform/utils/ComponentBus.ts | 1 + .../read_state/state/ReadStates.test.ts | 154 ++ .../features/read_state/state/ReadStates.ts | 55 +- .../state/read_states/ReadStateEntry.ts | 9 +- .../features/search/state/QuickSwitcher.tsx | 2 +- .../state/QuickSwitcherCandidateBuilder.ts | 27 + .../search/utils/DestinationSearchSources.ts | 39 +- .../src/features/search/utils/SearchUtils.ts | 9 +- .../threads/commands/ThreadCommands.ts | 249 +++ .../threads/commands/ThreadNavigation.ts | 48 + .../components/MessageThreadChip.module.css | 60 + .../threads/components/MessageThreadChip.tsx | 55 + .../components/RemoveFromThreadMenuItem.tsx | 55 + .../components/SidebarThreadList.module.css | 50 + .../threads/components/SidebarThreadList.tsx | 157 ++ .../components/ThreadBrowser.module.css | 177 ++ .../threads/components/ThreadBrowser.tsx | 409 ++++ .../components/ThreadComposerArea.module.css | 35 + .../threads/components/ThreadComposerArea.tsx | 74 + .../threads/components/ThreadContextMenu.tsx | 16 + .../components/ThreadCreatePane.module.css | 58 + .../threads/components/ThreadCreatePane.tsx | 195 ++ .../components/ThreadDefaultsSection.tsx | 46 + .../components/ThreadHeaderButtons.tsx | 157 ++ .../threads/components/ThreadMembersPanel.tsx | 136 ++ .../components/ThreadParentCrumb.module.css | 35 + .../threads/components/ThreadParentCrumb.tsx | 34 + .../components/ThreadSettingsModal.tsx | 148 ++ .../components/ThreadSidePanel.module.css | 92 + .../threads/components/ThreadSidePanel.tsx | 240 +++ .../ThreadSystemMessages.module.css | 19 + .../components/ThreadSystemMessages.tsx | 112 + .../ThreadWelcomeSection.module.css | 47 + .../components/ThreadWelcomeSection.tsx | 34 + .../features/threads/events/ForumUnreads.ts | 10 + .../features/threads/events/ThreadCreate.ts | 28 + .../features/threads/events/ThreadDelete.ts | 27 + .../features/threads/events/ThreadListSync.ts | 13 + .../threads/events/ThreadMemberListUpdate.ts | 10 + .../threads/events/ThreadMemberUpdate.ts | 16 + .../threads/events/ThreadMembersUpdate.ts | 46 + .../features/threads/events/ThreadUpdate.ts | 13 + .../threads/hooks/useThreadFetchOnMiss.ts | 22 + .../threads/hooks/useThreadMenuData.tsx | 261 +++ .../features/threads/hooks/useThreadSearch.ts | 67 + .../features/threads/state/ActiveComposer.ts | 44 + .../threads/state/ChannelThreads.test.ts | 545 +++++ .../features/threads/state/ChannelThreads.ts | 289 +++ .../features/threads/state/ThreadGuilds.ts | 131 ++ .../threads/state/ThreadMemberships.ts | 68 + .../src/features/threads/state/ThreadPanel.ts | 45 + .../threads/state/ThreadPanelWidth.ts | 34 + .../features/threads/state/ThreadRoster.ts | 101 + .../threads/state/ThreadSubscriptions.ts | 109 + .../threads/utils/SyncedPreferenceGuard.ts | 10 + .../threads/utils/ThreadActionRules.ts | 135 ++ .../threads/utils/ThreadAutoArchiveOptions.ts | 14 + .../threads/utils/ThreadMessageDescriptors.ts | 289 +++ .../threads/utils/ThreadNotificationUtils.ts | 69 + .../threads/utils/ThreadStarterSend.ts | 42 + .../features/threads/utils/ThreadViewUtils.ts | 26 + .../ui/action_menu/ChannelContextMenu.tsx | 2 +- .../ui/action_menu/ContextMenuIcons.tsx | 8 + .../action_menu/FluxerButtonContextMenu.tsx | 2 + .../ui/action_menu/GuildFolderContextMenu.tsx | 2 + .../ui/action_menu/GuildMemberContextMenu.tsx | 12 + .../ui/action_menu/MessageContextMenu.tsx | 4 +- .../ui/action_menu/items/ChannelMenuData.tsx | 19 +- .../ui/action_menu/items/ChannelMenuItems.tsx | 2 +- .../ui/action_menu/items/GuildMenuData.tsx | 12 +- .../ui/action_menu/items/GuildMenuItems.tsx | 12 +- .../ui/action_menu/items/MediaMenuData.tsx | 2 + .../ui/radio_group/RadioGroup.module.css | 4 + .../features/ui/radio_group/RadioGroup.tsx | 15 +- .../src/features/ui/state/Notification.ts | 42 +- .../applications_tab/ApplicationDetail.tsx | 20 +- .../keybinds_tab/SystemShortcutRowHint.tsx | 2 +- .../ChannelSettingsConstants.ts | 4 +- .../features/user/state/UserGuildSettings.ts | 1 + 258 files changed, 63052 insertions(+), 15599 deletions(-) create mode 100644 fluxer_app/src/features/channel/state/ChannelFrecency.test.ts create mode 100644 fluxer_app/src/features/forum/commands/ForumCommands.ts create mode 100644 fluxer_app/src/features/forum/components/Forum.module.css create mode 100644 fluxer_app/src/features/forum/components/ForumChannelView.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumPostActions.module.css create mode 100644 fluxer_app/src/features/forum/components/ForumPostActions.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumPostCard.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumPostComposer.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumPostContextMenu.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumPostTagsModal.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumTagPicker.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumTagPill.tsx create mode 100644 fluxer_app/src/features/forum/components/ForumToolbar.tsx create mode 100644 fluxer_app/src/features/forum/components/settings/ForumOverviewTab.tsx create mode 100644 fluxer_app/src/features/forum/components/settings/ForumTagEditModal.tsx create mode 100644 fluxer_app/src/features/forum/components/settings/ForumTagSettings.tsx create mode 100644 fluxer_app/src/features/forum/hooks/useForumPosts.ts create mode 100644 fluxer_app/src/features/forum/state/ForumPosts.test.ts create mode 100644 fluxer_app/src/features/forum/state/ForumPosts.ts create mode 100644 fluxer_app/src/features/forum/state/ForumReadState.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumChannelTypeOptions.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumChannelUtils.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumErrors.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumMessageDescriptors.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumNotificationUtils.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumPermissions.ts create mode 100644 fluxer_app/src/features/forum/utils/ForumPostList.ts create mode 100644 fluxer_app/src/features/forum/utils/MediaDownloadPolicy.ts create mode 100644 fluxer_app/src/features/guild/state/GuildMatureContentAgree.test.ts create mode 100644 fluxer_app/src/features/guild/utils/guild_tabs/audit_log/AuditLogForumPresentation.ts create mode 100644 fluxer_app/src/features/messaging/state/MessageQueueAttachmentUpload.test.ts create mode 100644 fluxer_app/src/features/messaging/upload/AttachmentUploadPlan.ts create mode 100644 fluxer_app/src/features/threads/commands/ThreadCommands.ts create mode 100644 fluxer_app/src/features/threads/commands/ThreadNavigation.ts create mode 100644 fluxer_app/src/features/threads/components/MessageThreadChip.module.css create mode 100644 fluxer_app/src/features/threads/components/MessageThreadChip.tsx create mode 100644 fluxer_app/src/features/threads/components/RemoveFromThreadMenuItem.tsx create mode 100644 fluxer_app/src/features/threads/components/SidebarThreadList.module.css create mode 100644 fluxer_app/src/features/threads/components/SidebarThreadList.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadBrowser.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadBrowser.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadComposerArea.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadComposerArea.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadContextMenu.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadCreatePane.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadCreatePane.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadDefaultsSection.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadHeaderButtons.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadParentCrumb.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadParentCrumb.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadSettingsModal.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadSidePanel.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadSidePanel.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadSystemMessages.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadSystemMessages.tsx create mode 100644 fluxer_app/src/features/threads/components/ThreadWelcomeSection.module.css create mode 100644 fluxer_app/src/features/threads/components/ThreadWelcomeSection.tsx create mode 100644 fluxer_app/src/features/threads/events/ForumUnreads.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadCreate.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadDelete.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadListSync.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadMemberListUpdate.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadMemberUpdate.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadMembersUpdate.ts create mode 100644 fluxer_app/src/features/threads/events/ThreadUpdate.ts create mode 100644 fluxer_app/src/features/threads/hooks/useThreadFetchOnMiss.ts create mode 100644 fluxer_app/src/features/threads/hooks/useThreadMenuData.tsx create mode 100644 fluxer_app/src/features/threads/hooks/useThreadSearch.ts create mode 100644 fluxer_app/src/features/threads/state/ActiveComposer.ts create mode 100644 fluxer_app/src/features/threads/state/ChannelThreads.test.ts create mode 100644 fluxer_app/src/features/threads/state/ChannelThreads.ts create mode 100644 fluxer_app/src/features/threads/state/ThreadGuilds.ts create mode 100644 fluxer_app/src/features/threads/state/ThreadMemberships.ts create mode 100644 fluxer_app/src/features/threads/state/ThreadPanel.ts create mode 100644 fluxer_app/src/features/threads/state/ThreadPanelWidth.ts create mode 100644 fluxer_app/src/features/threads/state/ThreadRoster.ts create mode 100644 fluxer_app/src/features/threads/state/ThreadSubscriptions.ts create mode 100644 fluxer_app/src/features/threads/utils/SyncedPreferenceGuard.ts create mode 100644 fluxer_app/src/features/threads/utils/ThreadActionRules.ts create mode 100644 fluxer_app/src/features/threads/utils/ThreadAutoArchiveOptions.ts create mode 100644 fluxer_app/src/features/threads/utils/ThreadMessageDescriptors.ts create mode 100644 fluxer_app/src/features/threads/utils/ThreadNotificationUtils.ts create mode 100644 fluxer_app/src/features/threads/utils/ThreadStarterSend.ts create mode 100644 fluxer_app/src/features/threads/utils/ThreadViewUtils.ts diff --git a/fluxer_app/index.html b/fluxer_app/index.html index 0fcdce6ef..e9f1b8821 100644 --- a/fluxer_app/index.html +++ b/fluxer_app/index.html @@ -25,7 +25,7 @@ - +
diff --git a/fluxer_app/src/app/Routes.ts b/fluxer_app/src/app/Routes.ts index f6030a4dc..57aa369ae 100644 --- a/fluxer_app/src/app/Routes.ts +++ b/fluxer_app/src/app/Routes.ts @@ -53,6 +53,8 @@ export const Routes = { channelId ? `/channels/${guildId}/${channelId}` : `/channels/${guildId}`, channelMessage: (guildId: string, channelId: string, messageId: string) => `${Routes.guildChannel(guildId, channelId)}/${messageId}`, + threadPanel: (guildId: string, channelId: string, threadId: string, messageId?: string) => + `${Routes.guildChannel(guildId, channelId)}/threads/${threadId}${messageId ? `/${messageId}` : ''}`, dmChannelMessage: (channelId: string, messageId: string) => `${Routes.dmChannel(channelId)}/${messageId}`, favoritesChannelMessage: (channelId: string, messageId: string) => `${Routes.favoritesChannel(channelId)}/${messageId}`, diff --git a/fluxer_app/src/app/router/components/GuildChannelRouter.tsx b/fluxer_app/src/app/router/components/GuildChannelRouter.tsx index fce3a91a1..706be8cb0 100644 --- a/fluxer_app/src/app/router/components/GuildChannelRouter.tsx +++ b/fluxer_app/src/app/router/components/GuildChannelRouter.tsx @@ -7,6 +7,8 @@ import {pickDefaultGuildChannelId} from '@app/features/messaging/utils/ChannelSh import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; import SelectedChannel from '@app/features/navigation/state/SelectedChannel'; import {useLocation} from '@app/features/platform/components/router/RouterReact'; +import ChannelThreads from '@app/features/threads/state/ChannelThreads'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {ME} from '@fluxer/constants/src/AppConstants'; import {observer} from 'mobx-react-lite'; @@ -24,16 +26,28 @@ function isGuildRootPath(pathname: string, guildId: string): boolean { return segments.length === 3 && segments[2] === guildId; } +function resolveDefaultChannelId(guildId: string): string | null { + let selectedChannelId = SelectedChannel.selectedChannelIds.get(guildId); + const threadsActive = ThreadGuilds.isActive(guildId); + if (threadsActive && selectedChannelId) { + const selectedThread = Channels.getChannel(selectedChannelId); + if (selectedThread?.isThread()) { + if (ChannelThreads.getThread(selectedThread.id)) return selectedThread.id; + selectedChannelId = selectedThread.parentId ?? undefined; + } + } + return pickDefaultGuildChannelId({ + guildId, + channels: Channels.getGuildChannels(guildId), + selectedChannelId, + threadsActive, + }); +} + export const GuildChannelRouter = observer<{guildId: string; children: React.ReactNode}>(({guildId, children}) => { const location = useLocation(); const needsDefaultChannel = !MobileLayout.enabled && isGuildRootPath(location.pathname, guildId); - const defaultChannelId = needsDefaultChannel - ? pickDefaultGuildChannelId({ - guildId, - channels: Channels.getGuildChannels(guildId), - selectedChannelId: SelectedChannel.selectedChannelIds.get(guildId), - }) - : null; + const defaultChannelId = needsDefaultChannel ? resolveDefaultChannelId(guildId) : null; useEffect(() => { if (!defaultChannelId) { return; diff --git a/fluxer_app/src/app/router/routes/AppRoutes.tsx b/fluxer_app/src/app/router/routes/AppRoutes.tsx index a315e2436..157f981e1 100644 --- a/fluxer_app/src/app/router/routes/AppRoutes.tsx +++ b/fluxer_app/src/app/router/routes/AppRoutes.tsx @@ -8,12 +8,14 @@ import {AppLayout} from '@app/features/app/components/layout/AppLayout'; import {DiscoveryLayout} from '@app/features/app/components/layout/DiscoveryLayout'; import {FavoritesLayout} from '@app/features/app/components/layout/FavoritesLayout'; import {GuildsLayout} from '@app/features/app/components/layout/GuildsLayout'; +import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import Authentication from '@app/features/auth/state/Authentication'; import {ChannelIndexPage} from '@app/features/channel/components/ChannelIndexPage'; import {ChannelLayout} from '@app/features/channel/components/ChannelLayout'; import {DMLayout} from '@app/features/channel/components/direct_message/DirectMessageLayout'; import Channels from '@app/features/channel/state/Channels'; +import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; import SelectedChannel from '@app/features/navigation/state/SelectedChannel'; import {navigateToLinkedUserProfile} from '@app/features/navigation/utils/DeepLinkUtils'; @@ -23,9 +25,11 @@ import { createNamedLoadableComponent, } from '@app/features/platform/components/loadable/LoadableComponent'; import {createRoute} from '@app/features/platform/components/router/RouterBuilder'; +import {notFound} from '@app/features/platform/components/router/RouterErrors'; import {useParams} from '@app/features/platform/components/router/RouterReact'; import {Redirect} from '@app/features/platform/components/router/RouterTypes'; import SessionManager from '@app/features/platform/state/AuthSession'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {ME} from '@fluxer/constants/src/AppConstants'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; @@ -331,6 +335,34 @@ const messageRoute = createRoute({ ), }); +const ThreadPanelRoutePage = observer(() => { + const {guildId} = useParams() as {guildId?: string}; + if (GatewayConnection.isReady && !ThreadGuilds.isActive(guildId)) { + return ; + } + return ( + + + + ); +}); +const threadPanelRoute = createRoute({ + getParentRoute: () => channelRoute, + id: 'threadPanel', + path: '/channels/:guildId/:channelId/threads/:threadId/:threadMessageId?', + onEnter: (ctx) => { + const {guildId, channelId} = ctx.params; + if (guildId === ME || (GatewayConnection.isReady && !ThreadGuilds.isActive(guildId))) { + return notFound(); + } + const channel = Channels.getChannel(channelId); + if (channel && channel.type === ChannelTypes.GUILD_CATEGORY) { + return new Redirect(Routes.guildChannel(guildId)); + } + return undefined; + }, + component: () => , +}); export const appRouteTree = appLayoutRoute.addChildren([ notificationsRoute, youRoute, @@ -343,6 +375,6 @@ export const appRouteTree = appLayoutRoute.addChildren([ plutoniumRoute, legacyPlutoniumRoute, favoritesRoute.addChildren([favoritesChannelRoute]), - channelsRoute.addChildren([membersRoute, channelRoute.addChildren([messageRoute])]), + channelsRoute.addChildren([membersRoute, channelRoute.addChildren([messageRoute, threadPanelRoute])]), ]), ]); diff --git a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx index b907af3b2..127663682 100644 --- a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx +++ b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx @@ -95,6 +95,10 @@ const YOU_RE_ALL_CAUGHT_UP_DESCRIPTOR = msg({ }); export function getUnreadChannels(): Array { + return getMarkableUnreadChannels().filter((channel) => !channel.isThreadOnly()); +} + +export function getMarkableUnreadChannels(): Array { const channelIds = ReadStates.getChannelIds(); const channels: Array = []; for (const channelId of channelIds) { diff --git a/fluxer_app/src/features/app/components/layout/ChannelItem.tsx b/fluxer_app/src/features/app/components/layout/ChannelItem.tsx index fd91ac116..62ee489a6 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelItem.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelItem.tsx @@ -35,6 +35,7 @@ import {getTypingText, usePresentableTypingUsers} from '@app/features/channel/co import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; +import {hasForumUnread} from '@app/features/forum/state/ForumReadState'; import type {Guild} from '@app/features/guild/models/Guild'; import { CREATE_CHANNEL_DESCRIPTOR, @@ -230,7 +231,7 @@ export const ChannelItem = observer( const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE; const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null); const unreadCount = ReadStates.getUnreadCount(channel.id); - const hasUnread = ReadStates.hasUnread(channel.id); + const hasUnread = channel.isThreadOnly() ? hasForumUnread(channel) : ReadStates.hasUnread(channel.id); const connectedVoiceGuildId = channelIsVoice ? MediaEngine.guildId : null; const connectedVoiceChannelId = channelIsVoice ? MediaEngine.channelId : null; const canManageChannels = Permission.can(Permissions.MANAGE_CHANNELS, channel); diff --git a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx index 1bdc50008..d73505bee 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx @@ -51,6 +51,8 @@ import {useLocation} from '@app/features/platform/components/router/RouterReact' import {failureCode} from '@app/features/platform/utils/ResponseInspection'; import ReadStates from '@app/features/read_state/state/ReadStates'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {SidebarThreadList} from '@app/features/threads/components/SidebarThreadList'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import {ChannelListContextMenu} from '@app/features/ui/action_menu/ChannelListContextMenu'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as DimensionCommands from '@app/features/ui/commands/DimensionCommands'; @@ -171,6 +173,7 @@ interface ChannelListContentProps { readonly banner: GuildBannerPresentation; } export const ChannelListContent = observer(({guild, scrollY, banner}: ChannelListContentProps) => { + const threadsActive = ThreadGuilds.isActive(guild.id); const {i18n} = useLingui(); const channels = Channels.getGuildChannels(guild.id); const location = useLocation(); @@ -625,6 +628,7 @@ export const ChannelListContent = observer(({guild, scrollY, banner}: ChannelLis /> )} {group.showTextChannels && + !threadsActive && group.visibleTextChannels.map((ch) => ( ))} + {group.showTextChannels && + threadsActive && + group.visibleTextChannels.map((ch) => ( + + + + + ))} {group.showVoiceChannels && group.visibleVoiceChannels.map((ch) => { const channelRow = ( diff --git a/fluxer_app/src/features/app/components/layout/dnd/ChannelReorderStateMachine.ts b/fluxer_app/src/features/app/components/layout/dnd/ChannelReorderStateMachine.ts index b048a6129..f8faf3c49 100644 --- a/fluxer_app/src/features/app/components/layout/dnd/ChannelReorderStateMachine.ts +++ b/fluxer_app/src/features/app/components/layout/dnd/ChannelReorderStateMachine.ts @@ -80,7 +80,9 @@ function isTextType(channelType: number): boolean { return ( channelType === ChannelTypes.GUILD_TEXT || channelType === ChannelTypes.GUILD_ANNOUNCEMENT || - channelType === ChannelTypes.GUILD_LINK + channelType === ChannelTypes.GUILD_LINK || + channelType === ChannelTypes.GUILD_FORUM || + channelType === ChannelTypes.GUILD_MEDIA ); } diff --git a/fluxer_app/src/features/app/components/layout/utils/ChannelMoveOperation.ts b/fluxer_app/src/features/app/components/layout/utils/ChannelMoveOperation.ts index 42d7a02ef..ddc71e0e4 100644 --- a/fluxer_app/src/features/app/components/layout/utils/ChannelMoveOperation.ts +++ b/fluxer_app/src/features/app/components/layout/utils/ChannelMoveOperation.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {DragItem, DropResult} from '@app/features/app/components/layout/types/DndTypes'; +import {isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization'; import type {Channel} from '@app/features/channel/models/Channel'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import { @@ -8,10 +9,6 @@ import { sortChannelsForOrdering, } from '@fluxer/schema/src/domains/channel/GuildChannelOrdering'; -const isTextChannel = (channel: Channel) => - channel.type === ChannelTypes.GUILD_TEXT || - channel.type === ChannelTypes.GUILD_ANNOUNCEMENT || - channel.type === ChannelTypes.GUILD_LINK; const isCategoryChannel = (channel: Channel) => channel.type === ChannelTypes.GUILD_CATEGORY; const gatherCategoryBlock = (channels: ReadonlyArray, categoryId: string) => { return channels.filter((ch) => ch.id === categoryId || ch.parentId === categoryId); diff --git a/fluxer_app/src/features/app/components/layout/utils/ChannelOrganization.ts b/fluxer_app/src/features/app/components/layout/utils/ChannelOrganization.ts index 5f11e4c1d..d5f6d6dc1 100644 --- a/fluxer_app/src/features/app/components/layout/utils/ChannelOrganization.ts +++ b/fluxer_app/src/features/app/components/layout/utils/ChannelOrganization.ts @@ -7,7 +7,8 @@ import {compareChannelOrdering} from '@fluxer/schema/src/domains/channel/GuildCh export const isTextChannel = (ch: Channel) => ch.type === ChannelTypes.GUILD_TEXT || ch.type === ChannelTypes.GUILD_ANNOUNCEMENT || - ch.type === ChannelTypes.GUILD_LINK; + ch.type === ChannelTypes.GUILD_LINK || + ch.isThreadOnly(); const isVoiceChannel = (ch: Channel) => ch.type === ChannelTypes.GUILD_VOICE; export const isCategory = (ch: Channel) => ch.type === ChannelTypes.GUILD_CATEGORY; diff --git a/fluxer_app/src/features/app/config/AuditLogConstants.ts b/fluxer_app/src/features/app/config/AuditLogConstants.ts index 5aaa54e32..bb6ecf9ed 100644 --- a/fluxer_app/src/features/app/config/AuditLogConstants.ts +++ b/fluxer_app/src/features/app/config/AuditLogConstants.ts @@ -375,12 +375,47 @@ export const AUDIT_LOG_ACTIONS: ReadonlyArray = [ }, ]; -export function getTranslatedAuditLogActions(i18n: I18n): Array<{ +const THREAD_CREATED_DESCRIPTOR = msg({ + message: 'Thread created', + comment: 'Activity log action filter option for threads that were started.', +}); +const THREAD_UPDATED_DESCRIPTOR = msg({ + message: 'Thread updated', + comment: 'Activity log action filter option for threads that were changed.', +}); +const THREAD_DELETED_DESCRIPTOR = msg({ + message: 'Thread deleted', + comment: 'Activity log action filter option for threads that were deleted.', +}); + +export const THREAD_AUDIT_LOG_ACTIONS: ReadonlyArray = [ + { + value: AuditLogActionType.THREAD_CREATE, + label: THREAD_CREATED_DESCRIPTOR, + targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL, + }, + { + value: AuditLogActionType.THREAD_UPDATE, + label: THREAD_UPDATED_DESCRIPTOR, + targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL, + }, + { + value: AuditLogActionType.THREAD_DELETE, + label: THREAD_DELETED_DESCRIPTOR, + targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL, + }, +]; + +export function getTranslatedAuditLogActions( + i18n: I18n, + {includeThreads = false}: {includeThreads?: boolean} = {}, +): Array<{ value: AuditLogActionType; label: string; targetType: AuditLogTargetType; }> { - return AUDIT_LOG_ACTIONS.map((action) => ({ + const actions = includeThreads ? [...AUDIT_LOG_ACTIONS, ...THREAD_AUDIT_LOG_ACTIONS] : AUDIT_LOG_ACTIONS; + return actions.map((action) => ({ ...action, label: i18n._(action.label), })); diff --git a/fluxer_app/src/features/app/constants/Endpoints.ts b/fluxer_app/src/features/app/constants/Endpoints.ts index aced51b6d..23ebd65a1 100644 --- a/fluxer_app/src/features/app/constants/Endpoints.ts +++ b/fluxer_app/src/features/app/constants/Endpoints.ts @@ -96,6 +96,19 @@ export const Endpoints = { CHANNEL_CALL: (channelId: string) => `/channels/${channelId}/call`, CHANNEL_CALL_RING: (channelId: string) => `/channels/${channelId}/call/ring`, CHANNEL_CALL_STOP_RINGING: (channelId: string) => `/channels/${channelId}/call/stop-ringing`, + CHANNEL_THREADS: (channelId: string) => `/channels/${channelId}/threads`, + CHANNEL_MESSAGE_THREADS: (channelId: string, messageId: string) => + `/channels/${channelId}/messages/${messageId}/threads`, + CHANNEL_THREADS_ARCHIVED_PUBLIC: (channelId: string) => `/channels/${channelId}/threads/archived/public`, + CHANNEL_THREADS_ARCHIVED_PRIVATE: (channelId: string) => `/channels/${channelId}/threads/archived/private`, + CHANNEL_THREADS_JOINED_ARCHIVED_PRIVATE: (channelId: string) => + `/channels/${channelId}/users/@me/threads/archived/private`, + CHANNEL_THREAD_MEMBER: (channelId: string, userId = ME) => `/channels/${channelId}/thread-members/${userId}`, + CHANNEL_THREAD_MEMBER_SETTINGS: (channelId: string) => `/channels/${channelId}/thread-members/@me/settings`, + CHANNEL_THREADS_SEARCH: (channelId: string) => `/channels/${channelId}/threads/search`, + CHANNEL_POST_DATA: (channelId: string) => `/channels/${channelId}/post-data`, + CHANNEL_TAGS: (channelId: string) => `/channels/${channelId}/tags`, + CHANNEL_TAG: (channelId: string, tagId: string) => `/channels/${channelId}/tags/${tagId}`, GUILDS: '/guilds', GUILD: (guildId: string) => `/guilds/${guildId}`, GUILD_CHANNELS: (guildId: string) => `/guilds/${guildId}/channels`, diff --git a/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts b/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts index 05f96753a..a7b42f32d 100644 --- a/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts +++ b/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts @@ -4,6 +4,7 @@ import type {Channel} from '@app/features/channel/models/Channel'; import type {Guild} from '@app/features/guild/models/Guild'; import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands'; import Messages from '@app/features/messaging/state/MessagingMessages'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {useCallback, useEffect, useRef} from 'react'; @@ -55,6 +56,9 @@ export function useChannelHoverPreload({ ) { return; } + if (channel.isThreadOnly() || (channel.isThread() && !ThreadGuilds.isActive(channel.guildId))) { + return; + } if (preloadMessages) { if (!Messages.preloadLatestPage(channel.id, guild?.id ?? channel.guildId ?? null)) { ensureMembersForCachedChannelMessages(channel.id); diff --git a/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/defaultHandlers.ts b/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/defaultHandlers.ts index 1d64c9928..7469bb577 100644 --- a/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/defaultHandlers.ts +++ b/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/defaultHandlers.ts @@ -37,6 +37,7 @@ import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateC import ReadStates from '@app/features/read_state/state/ReadStates'; import QuickSwitcher from '@app/features/search/state/QuickSwitcher'; import * as ThemeStudioCommands from '@app/features/theme_studio/commands/ThemeStudioCommands'; +import {getUnreadThreadIds} from '@app/features/threads/utils/ThreadViewUtils'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; @@ -326,6 +327,7 @@ export function registerDefaultKeybindHandlers(host: HandlerHost, i18n: I18n): v if (!guildId) return; const channels = Channels.getGuildChannels(guildId); const channelIds = channels.filter((channel) => ReadStates.hasUnread(channel.id)).map((channel) => channel.id); + channelIds.push(...getUnreadThreadIds(guildId)); if (channelIds.length > 0) { void ReadStateCommands.bulkAckChannels(channelIds); } diff --git a/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/messageHandlers.ts b/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/messageHandlers.ts index d07eb489a..54eb3bae4 100644 --- a/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/messageHandlers.ts +++ b/fluxer_app/src/features/app/keybindings/keybind_manager/handlers/messageHandlers.ts @@ -19,6 +19,7 @@ import { triggerAddReaction, } from '@app/features/channel/components/MessageActionUtils'; import MessageFocus from '@app/features/messaging/state/MessageFocus'; +import ActiveComposer from '@app/features/threads/state/ActiveComposer'; import type {I18n} from '@lingui/core'; export function registerMessageHandlers(host: HandlerHost, i18n: I18n): void { @@ -54,19 +55,19 @@ export function registerMessageHandlers(host: HandlerHost, i18n: I18n): void { }); host.register('message_reply_prev', ({type}) => { if (type !== 'press' || !host.currentChannelId) return; - requestAdjacentMessageReply(host.currentChannelId, -1); + requestAdjacentMessageReply(ActiveComposer.resolve(host.currentChannelId), -1); }); host.register('message_reply_next', ({type}) => { if (type !== 'press' || !host.currentChannelId) return; - requestAdjacentMessageReply(host.currentChannelId, 1); + requestAdjacentMessageReply(ActiveComposer.resolve(host.currentChannelId), 1); }); host.register('message_edit_prev', ({type}) => { if (type !== 'press' || !host.currentChannelId) return; - startAdjacentMessageEdit(host.currentChannelId, -1); + startAdjacentMessageEdit(ActiveComposer.resolve(host.currentChannelId), -1); }); host.register('message_edit_next', ({type}) => { if (type !== 'press' || !host.currentChannelId) return; - startAdjacentMessageEdit(host.currentChannelId, 1); + startAdjacentMessageEdit(ActiveComposer.resolve(host.currentChannelId), 1); }); host.register('message_forward', ({type, context}) => { if (type !== 'press') return; diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared.ts index a8f84d280..13a72ddb0 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared.ts @@ -42,6 +42,7 @@ export interface GuildSummary { name: string | null; icon: string | null; permissions?: string | null; + threads_active?: true; } export interface GuildWithPermissions { @@ -49,6 +50,7 @@ export interface GuildWithPermissions { name: string; icon: string | null; canAuthorizeBotInvite: boolean; + threadsActive: boolean; } export interface SignedInView { diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotGuilds.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotGuilds.ts index fbf56be80..962de3df2 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotGuilds.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/useBotGuilds.ts @@ -2,6 +2,7 @@ import type {GuildComboboxOption} from '@app/features/app/components/dialogs/shared/GuildComboboxRenderers'; import {Endpoints} from '@app/features/app/constants/Endpoints'; +import {parseRequestedBotPermissions} from '@app/features/auth/components/pages/oauth_authorize_page/hooks/usePermissionSelection'; import { type GuildSummary, type GuildWithPermissions, @@ -82,6 +83,7 @@ export function mapBotGuilds( userPermissions: permissionsValue, requestedPermissions, }), + threadsActive: guild.threads_active === true, }; }); } @@ -165,8 +167,9 @@ export function useBotGuilds(enabled: boolean, requestedPermissions: bigint): Bo return {status, guilds, options, labelById, error}; } -export function useBotInviteDestinations(enabled: boolean, requestedPermissions: bigint): BotInviteDestinationsResult { +export function useBotInviteDestinations(enabled: boolean, rawPermissions: string | null): BotInviteDestinationsResult { const {i18n} = useLingui(); + const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]); const i18nRef = useRef(i18n); i18nRef.current = i18n; const [status, setStatus] = useState(enabled ? 'loading' : 'idle'); diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/usePermissionSelection.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/usePermissionSelection.ts index d228df36b..fb0fe99e0 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/usePermissionSelection.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/hooks/usePermissionSelection.ts @@ -5,9 +5,10 @@ import { type BotPermissionOption, formatBotPermissionsQuery, getAllBotPermissions, + getPermissionFlagByKey, } from '@app/features/permissions/utils/PermissionUtils'; import {normalizeBotInvitePermissions} from '@fluxer/constants/src/BotPermissionUtils'; -import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_AWARE_ALL_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils'; import {useLingui} from '@lingui/react/macro'; import {useCallback, useMemo, useState} from 'react'; @@ -22,25 +23,32 @@ export interface PermissionSelection { toBitfield: () => string | undefined; } -export function usePermissionSelection(rawPermissions: string | null): PermissionSelection { +export function parseRequestedBotPermissions(rawPermissions: string | null, threadsActive: boolean): bigint { + if (!rawPermissions) return 0n; + try { + const parsed = BigInt(rawPermissions); + if (parsed < 0n) return 0n; + return threadsActive + ? normalizeBotInvitePermissions(parsed, THREAD_AWARE_ALL_PERMISSIONS) + : normalizeBotInvitePermissions(parsed); + } catch (err) { + logger.warn('Failed to parse requested permissions', err); + return 0n; + } +} + +export function usePermissionSelection(rawPermissions: string | null, threadsActive: boolean): PermissionSelection { const {i18n} = useLingui(); - const options = useMemo(() => getAllBotPermissions(i18n), [i18n.locale]); - const requestedBitfield = useMemo(() => { - if (!rawPermissions) return 0n; - try { - const parsed = BigInt(rawPermissions); - if (parsed < 0n) return 0n; - return normalizeBotInvitePermissions(parsed); - } catch (err) { - logger.warn('Failed to parse requested permissions', err); - return 0n; - } - }, [rawPermissions]); + const options = useMemo(() => getAllBotPermissions(i18n, {threads: threadsActive}), [i18n.locale, threadsActive]); + const requestedBitfield = useMemo( + () => parseRequestedBotPermissions(rawPermissions, threadsActive), + [rawPermissions, threadsActive], + ); const requestedKeys = useMemo>(() => { if (!rawPermissions) return []; return options .filter((opt) => { - const flag = Permissions[opt.id as keyof typeof Permissions]; + const flag = getPermissionFlagByKey(opt.id); return flag != null && (requestedBitfield & flag) === flag; }) .map((opt) => opt.id); diff --git a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts index 0dd59b787..265b1c6d8 100644 --- a/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts +++ b/fluxer_app/src/features/auth/components/pages/oauth_authorize_page/state/useAuthorizeFlow.ts @@ -31,6 +31,7 @@ import type {BotPermissionOption} from '@app/features/permissions/utils/Permissi import {http} from '@app/features/platform/transport/RestTransport'; import {HttpError} from '@app/features/platform/types/EndpointError'; import {failureMessage} from '@app/features/platform/utils/ResponseInspection'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -183,8 +184,15 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori const initialMissingClientId = !params; const publicAppState = useOAuthPublicApp(params?.clientId ?? null); const scopeSelection = useScopeSelection(scopes); - const permissionSelection = usePermissionSelection(params?.permissions ?? null); - const destinations = useBotInviteDestinations(hasBotScope, permissionSelection.requestedBitfield); + const destinations = useBotInviteDestinations(hasBotScope, params?.permissions ?? null); + const selectedDestinationTarget = parseBotInviteDestinationKey(selectedDestinationKey); + const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null; + const permissionSelection = usePermissionSelection( + params?.permissions ?? null, + selectedGuildId != null && + (ThreadGuilds.isActive(selectedGuildId) || + destinations.guilds.some((guild) => guild.id === selectedGuildId && guild.threadsActive)), + ); const initialReviewStep: ReviewStep = includeAccountStep ? 'account' : 'scopes'; const preFetchValidationError = useMemo(() => { if (!params) return i18n._(MISSING_CLIENT_ID_DESCRIPTOR); diff --git a/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx b/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx index ea98ac192..7240c20bf 100644 --- a/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx +++ b/fluxer_app/src/features/channel/components/ChannelAttachmentArea.tsx @@ -636,7 +636,7 @@ export const ChannelAttachmentArea = observer(({channelId}: {channelId: string}) forceJumpFrameRef.current = null; const messages = Messages.getMessages(channelIdRef.current); if (messages.hasMoreAfter) { - ComponentBus.dispatch('FORCE_JUMP_TO_PRESENT'); + ComponentBus.dispatch('FORCE_JUMP_TO_PRESENT', {channelId: channelIdRef.current}); } }); }, []); diff --git a/fluxer_app/src/features/channel/components/ChannelHeader.module.css b/fluxer_app/src/features/channel/components/ChannelHeader.module.css index 8dc15c8e6..99cef154b 100644 --- a/fluxer_app/src/features/channel/components/ChannelHeader.module.css +++ b/fluxer_app/src/features/channel/components/ChannelHeader.module.css @@ -501,6 +501,25 @@ max-width: 15.25rem; } +.headerContainerThreadPanelOpen { + grid-template-columns: minmax(min(12rem, 40%), 1fr) minmax(0, auto); +} + +.headerContainerThreadPanelOpen .topicDivider, +.headerContainerThreadPanelOpen .topicContainer { + display: none; +} + +.headerContainerThreadPanelOpen .messageSearchFocusWrapper { + flex: 0 1 15.25rem; + min-width: 9rem; +} + +.headerContainerThreadPanelOpen .messageSearchFocusWrapper > :first-child { + width: 100%; + min-width: 0; +} + .iconButton { display: flex; align-items: center; diff --git a/fluxer_app/src/features/channel/components/ChannelHeader.tsx b/fluxer_app/src/features/channel/components/ChannelHeader.tsx index 8eef32140..2281cd70d 100644 --- a/fluxer_app/src/features/channel/components/ChannelHeader.tsx +++ b/fluxer_app/src/features/channel/components/ChannelHeader.tsx @@ -84,6 +84,10 @@ import {AddFriendsToGroupModal} from '@app/features/relationship/components/moda import Relationships from '@app/features/relationship/state/Relationships'; import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager'; import markupStyles from '@app/features/theme/styles/Markup.module.css'; +import {ThreadContextMenu} from '@app/features/threads/components/ThreadContextMenu'; +import {ThreadNotificationSettingsButton, ThreadsButton} from '@app/features/threads/components/ThreadHeaderButtons'; +import {ThreadParentCrumb} from '@app/features/threads/components/ThreadParentCrumb'; +import {useThreadPanelState} from '@app/features/threads/components/ThreadSidePanel'; import {ChannelContextMenu} from '@app/features/ui/action_menu/ChannelContextMenu'; import {DMContextMenu} from '@app/features/ui/action_menu/DMContextMenu'; import {GroupDMContextMenu} from '@app/features/ui/action_menu/GroupDMContextMenu'; @@ -381,6 +385,8 @@ export const ChannelHeader = observer( RouterUtils.transitionTo(Routes.ME); } else if (Routes.isFavoritesRoute(location.pathname)) { RouterUtils.transitionTo(Routes.FAVORITES); + } else if (channel?.isThread() && channel.guildId && channel.parentId) { + NavigationCommands.selectChannel(channel.guildId, channel.parentId); } else if (isGuildChannel && channel?.guildId) { NavigationCommands.selectChannel(channel.guildId); } else { @@ -402,6 +408,18 @@ export const ChannelHeader = observer( }; const handleContextMenu = useCallback( (event: React.MouseEvent) => { + if (channel?.isThread()) { + event.preventDefault(); + event.stopPropagation(); + ContextMenuCommands.openFromEvent(event, ({onClose}) => ( + + )); + return; + } if (channel && isGuildChannel) { event.preventDefault(); event.stopPropagation(); @@ -557,6 +575,8 @@ export const ChannelHeader = observer( mobileActionCount += 1; } const searchPanelOpen = Boolean(isSearchResultsVisible); + const threadPanelState = useThreadPanelState(channel); + const isThreadPanelOpen = threadPanelState.thread !== undefined || threadPanelState.createMessageId !== undefined; useSkeletonLayoutReport( () => { if (channelId == null || channelType == null) { @@ -584,7 +604,11 @@ export const ChannelHeader = observer( data-flx="channel.channel-header.header-wrapper" >
@@ -848,6 +872,13 @@ export const ChannelHeader = observer( onContextMenu={handleContextMenu} data-flx="channel.channel-header.channel-info-container.context-menu" > + {channel.isThread() && ( + + )} {ChannelUtils.getIcon(channel, {className: styles.channelIcon}, e2eeIconOptions)} )} - {isMobile && channel && !isPersonalNotes && Accessibility.showFavorites && ( - - - - )} + {isMobile && + channel && + !isPersonalNotes && + !channel.isThread() && + !channel.isThreadOnly() && + Accessibility.showFavorites && ( + + + + )} {isMobile && (isDM || isGroupDM) && !isPersonalNotes && ( <> @@ -978,6 +1014,9 @@ export const ChannelHeader = observer( )} + {isMobile && channel && ( + + )} {isMobile && isGuildChannel && ( - - - )} + {channel && + !isMobile && + !isPersonalNotes && + !channel.isThread() && + !channel.isThreadOnly() && + Accessibility.showFavorites && ( + + + + + + )} {showMembersToggle && !isMobile && ( )} - {!isMobile && channel && !isPersonalNotes && ( + {!isMobile && channel && !isPersonalNotes && !channel.isThreadOnly() && (
{ const channel = Channels.getChannel(channelId); const guildId = routeGuildId || channel?.guildId; const guild = guildId ? Guilds.getGuild(guildId) : null; + const threadLookupPending = useThreadFetchOnMiss(guild ? guildId : undefined, channel ? undefined : channelId); + if (guild && !channel && threadLookupPending) { + return null; + } if (guild && !channel) { return (
{ + const onForceJumpToPresent = (payload?: unknown) => { + const data = payload as {channelId?: string} | undefined; + if (data?.channelId && data.channelId !== channel.id) return; MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL); }; - const onScrollPageUp = () => scrollManager.pageBackward(true); - const onScrollPageDown = () => scrollManager.pageForward(true); + const onScrollPageUp = () => { + if (ActiveComposer.accepts(channel.id)) scrollManager.pageBackward(true); + }; + const onScrollPageDown = () => { + if (ActiveComposer.accepts(channel.id)) scrollManager.pageForward(true); + }; const onLayoutResized = (payload?: unknown) => { const data = payload as {channelId?: string} | undefined; if (data?.channelId && data.channelId !== channel.id) return; @@ -465,7 +474,11 @@ export const Messages = observer(function Messages({ } }, [state.editingMessageId, scrollManager]); useEffect(() => { - if (!windowNeedsPage || !isGatewayConnected || selectedChannelId !== channel.id) { + if ( + !windowNeedsPage || + !isGatewayConnected || + (selectedChannelId !== channel.id && openThreadPanelId !== channel.id) + ) { if (recoveryFetchChannelIdRef.current === channel.id) { recoveryFetchChannelIdRef.current = null; } @@ -480,7 +493,7 @@ export const Messages = observer(function Messages({ recoveryFetchChannelIdRef.current = null; } }); - }, [channel.id, isGatewayConnected, selectedChannelId, windowNeedsPage, state.messageVersion]); + }, [channel.id, isGatewayConnected, selectedChannelId, openThreadPanelId, windowNeedsPage, state.messageVersion]); useMessageListKeyboardNavigation({ containerRef: scrollManager.ref, channelId: channel.id, diff --git a/fluxer_app/src/features/channel/components/ChannelTextarea.tsx b/fluxer_app/src/features/channel/components/ChannelTextarea.tsx index e7206911c..9fccdf28e 100644 --- a/fluxer_app/src/features/channel/components/ChannelTextarea.tsx +++ b/fluxer_app/src/features/channel/components/ChannelTextarea.tsx @@ -13,9 +13,10 @@ import {observer} from 'mobx-react-lite'; interface ChannelTextareaProps { readonly channel: Channel; readonly inputSuppressed?: boolean; + readonly placeholder?: string; } -export const ChannelTextarea = observer(({channel, inputSuppressed = false}: ChannelTextareaProps) => { +export const ChannelTextarea = observer(({channel, inputSuppressed = false, placeholder}: ChannelTextareaProps) => { const draft = Drafts.getDraft(channel.id); const draftSegments = Drafts.getDraftSegments(channel.id); const forceNoSendMessages = DeveloperOptions.forceNoSendMessages; @@ -38,6 +39,7 @@ export const ChannelTextarea = observer(({channel, inputSuppressed = false}: Cha draftSegments={draftSegments} disabled={disabled} inputSuppressed={inputSuppressed} + placeholder={placeholder} data-flx="channel.channel-textarea.lexical-channel-textarea-content" /> ); diff --git a/fluxer_app/src/features/channel/components/ChannelUserTag.tsx b/fluxer_app/src/features/channel/components/ChannelUserTag.tsx index 0d29f7973..8ee6b192e 100644 --- a/fluxer_app/src/features/channel/components/ChannelUserTag.tsx +++ b/fluxer_app/src/features/channel/components/ChannelUserTag.tsx @@ -8,12 +8,18 @@ import React from 'react'; interface UserTagProps extends React.ComponentPropsWithoutRef<'span'> { className?: string; system?: boolean; - variant?: 'bot' | 'system' | 'community'; + variant?: 'bot' | 'system' | 'community' | 'op'; size?: 'sm' | 'lg'; } -function renderTagLabel(variant: 'bot' | 'system' | 'community') { +function renderTagLabel(variant: 'bot' | 'system' | 'community' | 'op') { switch (variant) { + case 'op': + return ( + + OP + + ); case 'system': return System; case 'community': diff --git a/fluxer_app/src/features/channel/components/ChannelWelcomeSection.tsx b/fluxer_app/src/features/channel/components/ChannelWelcomeSection.tsx index 9c7c30fea..fd8923988 100644 --- a/fluxer_app/src/features/channel/components/ChannelWelcomeSection.tsx +++ b/fluxer_app/src/features/channel/components/ChannelWelcomeSection.tsx @@ -6,6 +6,7 @@ import {GroupDMWelcomeSection} from '@app/features/channel/components/direct_mes import {PersonalNotesWelcomeSection} from '@app/features/channel/components/direct_message/PersonalNotesWelcomeSection'; import type {Channel} from '@app/features/channel/models/Channel'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; +import {ThreadWelcomeSection} from '@app/features/threads/components/ThreadWelcomeSection'; import Users from '@app/features/user/state/Users'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {Trans} from '@lingui/react/macro'; @@ -40,6 +41,9 @@ export const ChannelWelcomeSection = observer(({channel}: ChannelWelcomeSectionP ); } + if (channel.isThread()) { + return ; + } const channelDisplayName = `#${channel.name ?? ''}`; return (
diff --git a/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx b/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx index 57c6d6809..f97f27c68 100644 --- a/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx +++ b/fluxer_app/src/features/channel/components/CompactMessageLayout.tsx @@ -6,6 +6,7 @@ import {MessageAvatar} from '@app/features/channel/components/MessageAvatar'; import {MessageTimeoutIndicator} from '@app/features/channel/components/MessageTimeoutIndicator'; import {MessageUsername} from '@app/features/channel/components/MessageUsername'; import {TimestampWithTooltip} from '@app/features/channel/components/TimestampWithTooltip'; +import {isOriginalPoster} from '@app/features/forum/utils/ForumChannelUtils'; import type {Guild} from '@app/features/guild/models/Guild'; import type {GuildMember} from '@app/features/member/models/GuildMember'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; @@ -184,6 +185,13 @@ export function CompactAuthorPrefix({ data-flx="channel.compact-message-layout.compact-author-prefix.user-tag-compact" /> )} + {isOriginalPoster(message.channelId, author.id) && ( + + )} {showAvatar && ( void; + insertEmoji: (emoji: FlatEmoji) => void; + uploadFiles: () => void; + focus: () => void; +} +const NON_COMMAND_TRIGGERS: Array = ['mention', 'channel', 'emoji', 'meme', 'gif', 'sticker']; + export const LexicalChannelTextareaContent = observer( ({ channel, @@ -147,6 +158,16 @@ export const LexicalChannelTextareaContent = observer( disabled, inputSuppressed = false, messageSafetyGateActive = false, + typingEnabled = true, + onSubmit: onSubmitOverride, + enableReply = true, + enableEditLast = true, + enableSlashCommands = true, + draftChannelId, + placeholder, + bare = false, + controlsRef, + onValueChange, }: { channel: Channel; draft: string | null; @@ -154,8 +175,19 @@ export const LexicalChannelTextareaContent = observer( disabled: boolean; inputSuppressed?: boolean; messageSafetyGateActive?: boolean; + typingEnabled?: boolean; + onSubmit?: SendMessageFunction; + enableReply?: boolean; + enableEditLast?: boolean; + enableSlashCommands?: boolean; + draftChannelId?: string; + placeholder?: string; + bare?: boolean; + controlsRef?: React.MutableRefObject; + onValueChange?: (value: string) => void; }) => { const {i18n} = useLingui(); + const composerKey = draftChannelId ?? channel.id; const editingMessageId = MessageEdit.getEditingMessageId(channel.id); const editingMobileMessageId = MessageEditMobile.getEditingMobileMessageId(channel.id); const isEditingMessageInComposer = editingMobileMessageId != null; @@ -268,15 +300,15 @@ export const LexicalChannelTextareaContent = observer( `${desktopComposerActionCount}|${mobileComposerActionCount}|${showMessageSendButton}`, ); const mobileLayout = MobileLayout; - const replyingMessage = MessageReply.getReplyingMessage(channel.id); - const referencedMessage = MessageReply.getReferencedMessage(channel.id); + const replyingMessage = enableReply ? MessageReply.getReplyingMessage(channel.id) : null; + const referencedMessage = enableReply ? MessageReply.getReferencedMessage(channel.id) : null; const editingMessage = editingMobileMessageId ? Messages.getMessage(channel.id, editingMobileMessageId) : null; const editingMessageForComposer = editingMessage === undefined ? null : editingMessage; const isEditingMessageOnMobile = editingMessageForComposer !== null && mobileLayout.enabled; const maxMessageLength = Limits.getMaxMessageLength(); const premiumMaxLength = Limits.getStockValue('max_message_length', maxMessageLength); const maxAttachments = Limits.getMaxAttachmentsPerMessage(); - const uploadAttachments = useTextareaAttachments(channel.id); + const uploadAttachments = useTextareaAttachments(composerKey); const {isSlowmodeActive, slowmodeRemaining, isSlowmodeEnabled, isSlowmodeImmune} = useSlowmode(channel); const rateLimitPerUser = channel.rateLimitPerUser === undefined ? 0 : channel.rateLimitPerUser; const shouldFetchSlowmode = Boolean(channel.guildId) && rateLimitPerUser > 0; @@ -343,7 +375,7 @@ export const LexicalChannelTextareaContent = observer( }); const handleSendMessage: SendMessageFunction = useCallback( (...args) => { - if (!sendMessage(...args)) { + if (!(onSubmitOverride ?? sendMessage)(...args)) { return false; } rememberSegmentsForValue(value); @@ -355,7 +387,22 @@ export const LexicalChannelTextareaContent = observer( clearSegments(); return true; }, - [sendMessage, clearSegments, rememberSegmentsForValue, value], + [onSubmitOverride, sendMessage, clearSegments, rememberSegmentsForValue, value], + ); + const sendExpressionMessage: typeof sendOptimisticMessage = useCallback( + (messageData, sendOptions) => { + if (!onSubmitOverride) { + sendOptimisticMessage(messageData, sendOptions); + return; + } + onSubmitOverride( + messageData.content, + sendOptions.hasAttachments, + messageData.stickers ?? [], + sendOptions.favoriteMemeId, + ); + }, + [onSubmitOverride, sendOptimisticMessage], ); const sendMentionConfirmationEvent = useCallback((event: MentionConfirmationEvent) => { setMentionConfirmationSnapshot((snapshot) => transitionMentionConfirmationSnapshot(snapshot, event)); @@ -506,6 +553,7 @@ export const LexicalChannelTextareaContent = observer( } = useLexicalAutocomplete({ channel, handleRef, + allowedTriggers: enableSlashCommands ? undefined : NON_COMMAND_TRIGGERS, i18n, }); const isAutocompleteVisible = !textareaInputDisabled && isAutocompleteAttached; @@ -542,6 +590,9 @@ export const LexicalChannelTextareaContent = observer( if (!isSlowmodeActive || isEditingMessageInComposer) { return false; } + if (!enableSlashCommands) { + return true; + } const resolution = LexicalMessageCommandResolver.resolve(handleRef.current); if (resolution.status === LexicalMessageCommandResolutionStatus.NO_COMMAND) { return true; @@ -550,9 +601,10 @@ export const LexicalChannelTextareaContent = observer( return false; } return CommandUtils.doesCommandSendCurrentChannelMessage(resolution.command); - }, [handleRef, isEditingMessageInComposer, isSlowmodeActive, value]); + }, [enableSlashCommands, handleRef, isEditingMessageInComposer, isSlowmodeActive, value]); useChannelComposerPaste({ channel, + attachmentKey: composerKey, handleRef, editableRef, isFocused, @@ -584,6 +636,7 @@ export const LexicalChannelTextareaContent = observer( files, uploadAttachments.length, maxAttachments, + composerKey, ); if (!result.success) { if (result.error === 'too_many_attachments') { @@ -610,7 +663,7 @@ export const LexicalChannelTextareaContent = observer( handle.focus(); } } - }, [channel, textareaInputDisabled, maxAttachments, uploadAttachments.length]); + }, [channel, composerKey, textareaInputDisabled, maxAttachments, uploadAttachments.length]); const handleUploadMessageAsFile = useCallback(async () => { if (textareaInputDisabled) { return; @@ -624,6 +677,7 @@ export const LexicalChannelTextareaContent = observer( value, uploadAttachments.length, maxAttachments, + composerKey, ); if (!result.success) { if (result.error === 'too_many_attachments') { @@ -649,11 +703,11 @@ export const LexicalChannelTextareaContent = observer( handle.clear(); } setValue(''); - DraftCommands.deleteDraft(channel.id); + DraftCommands.deleteDraft(composerKey); if (handle !== null) { handle.focus(); } - }, [textareaInputDisabled, value, channel, uploadAttachments.length, maxAttachments]); + }, [textareaInputDisabled, value, channel, uploadAttachments.length, maxAttachments, composerKey]); useTextareaExpressionHandlers({ setValue, textareaRef: nullTextareaRef, @@ -662,8 +716,9 @@ export const LexicalChannelTextareaContent = observer( previousValueRef, prepareTextChange, segmentManagerRef, - sendOptimisticMessage, + sendOptimisticMessage: sendExpressionMessage, enabled: !textareaInputDisabled, + channelId: draftChannelId ?? channel.id, }); const {expressionPickerOpen, setExpressionPickerOpen, handleExpressionPickerTabToggle, selectedTab} = useTextareaExpressionPicker({ @@ -745,6 +800,7 @@ export const LexicalChannelTextareaContent = observer( isSlowmodeActive, handleSendMessage, onMentionConfirmationNeeded: handleMentionConfirmationNeeded, + commandsEnabled: enableSlashCommands, i18n: i18n, }); const handleClearSlashCommand = useCallback(() => { @@ -754,11 +810,11 @@ export const LexicalChannelTextareaContent = observer( } setValue(''); clearSegments(); - DraftCommands.deleteDraft(channel.id); + DraftCommands.deleteDraft(draftChannelId ?? channel.id); if (handle !== null) { handle.focus(); } - }, [channel.id, clearSegments]); + }, [channel.id, clearSegments, draftChannelId]); const handleCancelEdit = useCallback(() => { setValue(''); clearSegments(); @@ -781,7 +837,7 @@ export const LexicalChannelTextareaContent = observer( return true; } if (hasAttachments) { - CloudUpload.clearTextarea(channel.id); + CloudUpload.clearTextarea(composerKey); focusComposer(); return true; } @@ -804,6 +860,7 @@ export const LexicalChannelTextareaContent = observer( return false; }, [ channel.id, + composerKey, editingMessageForComposer, focusComposer, handleCancelEdit, @@ -886,20 +943,42 @@ export const LexicalChannelTextareaContent = observer( } onSubmit(); }, [canSubmit, channel, hasAttachments, onSubmit]); + useEffect(() => { + if (!controlsRef) return; + controlsRef.current = { + submit: handleSubmit, + insertEmoji: (emoji) => { + insertComposerEmoji(handleRef.current, emoji, {reactionShorthand: true}); + }, + uploadFiles: () => { + void handleFileButtonClick(); + }, + focus: () => handleRef.current?.focus(), + }; + return () => { + controlsRef.current = null; + }; + }, [controlsRef, handleSubmit, handleFileButtonClick]); + useEffect(() => { + onValueChange?.(value); + }, [onValueChange, value]); const handleArrowUpEmpty = useCallback(() => { if (KeyboardMode.keyboardModeEnabled) { ComponentBus.dispatch('FOCUS_BOTTOMMOST_MESSAGE', {channelId: channel.id}); return true; } + if (!enableEditLast) { + return false; + } const message = Messages.getLastEditableMessage(channel.id); if (!message) { return false; } MessageCommands.startEdit(channel.id, message.id, message.content); return true; - }, [channel.id]); + }, [channel.id, enableEditLast]); useTextareaDraftAndTyping({ - channelId: channel.id, + channelId: draftChannelId ?? channel.id, value, setValue, draft, @@ -907,7 +986,7 @@ export const LexicalChannelTextareaContent = observer( previousValueRef, segmentManagerRef, enabled: !disabled, - typingEnabled: !textareaInputDisabled, + typingEnabled: typingEnabled && !textareaInputDisabled, isEditingMessageInComposer, }); useChannelComposerGlobalShortcuts({ @@ -918,13 +997,16 @@ export const LexicalChannelTextareaContent = observer( isFocused, handleArrowUpEmpty, }); - const placeholderText = disabled - ? i18n._(YOU_DO_NOT_HAVE_PERMISSION_TO_SEND_MESSAGES_DESCRIPTOR) - : channel.guildId != null - ? i18n._(MESSAGE_CHANNEL_DESCRIPTOR, {channelName: channel.name || i18n._(CHANNEL_DESCRIPTOR)}) - : channel.isDM() - ? i18n._(MESSAGE_USER_DESCRIPTOR, {userName: ChannelDisplayUtils.getDMDisplayName(channel)}) - : i18n._(MESSAGE_GROUP_DESCRIPTOR, {groupName: ChannelDisplayUtils.getDMDisplayName(channel)}); + const placeholderText = placeholder + ? placeholder + : disabled + ? i18n._(YOU_DO_NOT_HAVE_PERMISSION_TO_SEND_MESSAGES_DESCRIPTOR) + : channel.guildId != null + ? i18n._(MESSAGE_CHANNEL_DESCRIPTOR, {channelName: channel.name || i18n._(CHANNEL_DESCRIPTOR)}) + : channel.isDM() + ? i18n._(MESSAGE_USER_DESCRIPTOR, {userName: ChannelDisplayUtils.getDMDisplayName(channel)}) + : i18n._(MESSAGE_GROUP_DESCRIPTOR, {groupName: ChannelDisplayUtils.getDMDisplayName(channel)}); + useEffect(() => ActiveComposer.register(draftChannelId ?? channel.id), [channel.id, draftChannelId]); useEffect(() => { const unsubscribe = ComponentBus.subscribe('FOCUS_TEXTAREA', (payload?: unknown) => { const payloadValue = payload === null || payload === undefined ? {} : payload; @@ -932,7 +1014,7 @@ export const LexicalChannelTextareaContent = observer( channelId?: string; enterKeyboardMode?: boolean; }; - if (channelId && channelId !== channel.id) return; + if (channelId ? channelId !== composerKey : !ActiveComposer.accepts(composerKey)) return; if (textareaInputDisabled) return false; if (editingMessageId && !mobileLayout.enabled) return false; if (enterKeyboardMode) { @@ -947,7 +1029,7 @@ export const LexicalChannelTextareaContent = observer( return true; }); return unsubscribe; - }, [channel.id, editingMessageId, textareaInputDisabled, mobileLayout.enabled]); + }, [composerKey, editingMessageId, textareaInputDisabled, mobileLayout.enabled]); const wasEditingInlineRef = useRef(false); useEffect(() => { const isEditingInline = editingMessageId != null && !mobileLayout.enabled; @@ -964,22 +1046,22 @@ export const LexicalChannelTextareaContent = observer( const unsubscribe = ComponentBus.subscribe('TEXTAREA_UPLOAD_FILE', (payload?: unknown) => { const payloadValue = payload === null || payload === undefined ? {} : payload; const {channelId} = payloadValue as {channelId?: string}; - if (channelId && channelId !== channel.id) return; + if (channelId ? channelId !== composerKey : !ActiveComposer.accepts(composerKey)) return; handleFileButtonClick(); }); return unsubscribe; - }, [channel.id, textareaInputDisabled, handleFileButtonClick]); + }, [composerKey, textareaInputDisabled, handleFileButtonClick]); useEffect(() => { const unsubscribe = ComponentBus.subscribe('TEXTAREA_SEND_VOICE_MESSAGE', (payload?: unknown) => { const payloadValue = payload === null || payload === undefined ? {} : payload; const {channelId} = payloadValue as {channelId?: string}; - if (channelId && channelId !== channel.id) return undefined; - if (mobileLayout.enabled || textareaInputDisabled) return false; + if (channelId ? channelId !== composerKey : !ActiveComposer.accepts(composerKey)) return undefined; + if (mobileLayout.enabled || textareaInputDisabled || draftChannelId != null) return false; openVoiceMessageComposerModal(channel.id); return true; }); return unsubscribe; - }, [channel.id, textareaInputDisabled, mobileLayout.enabled]); + }, [channel.id, composerKey, draftChannelId, textareaInputDisabled, mobileLayout.enabled]); useEffect(() => { if (mobileLayout.enabled) { setShowAllButtons(true); @@ -1105,7 +1187,7 @@ export const LexicalChannelTextareaContent = observer( textareaValue={value} onUploadAsFile={handleUploadMessageAsFile} onSendVoiceMessage={ - mobileLayout.enabled + mobileLayout.enabled || draftChannelId != null ? undefined : () => { ContextMenuCommands.close(); @@ -1114,6 +1196,14 @@ export const LexicalChannelTextareaContent = observer( } } } + onCreateThread={ + draftChannelId == null && canStartThreadIn(channel) + ? () => { + ContextMenuCommands.close(); + openCreateThread(channel, null); + } + : undefined + } data-flx="channel.lexical-channel-textarea-content.open-plus-menu.textarea-plus-menu" /> ), @@ -1233,52 +1323,58 @@ export const LexicalChannelTextareaContent = observer( wrapperStyles.composerRoot, wrapperStyles.wrapperSides, styles.textareaOuter, - mobileLayout.enabled && styles.textareaOuterMobile, - wrapperStyles.roundedAll, + bare && styles.textareaOuterBare, + !bare && mobileLayout.enabled && styles.textareaOuterMobile, + !bare && wrapperStyles.roundedAll, textareaInputDisabled && wrapperStyles.disabled, - !mobileLayout.enabled && styles.textareaOuterRow, + !bare && !mobileLayout.enabled && styles.textareaOuterRow, )} data-flx="channel.lexical-channel-textarea-content.textarea-outer" > - - - {isTypingStatusVisible && ( - - - - )} - - - {isSlowmodeIndicatorVisible && ( - - + + + )} + - - )} - + + {isSlowmodeIndicatorVisible && ( + + + + )} + + )} {hasLeadingStatusContent && (
{topBarContent !== null && ( @@ -1300,7 +1396,7 @@ export const LexicalChannelTextareaContent = observer( {showAttachments && renderSection( , styles.collapsibleSection, @@ -1316,27 +1412,33 @@ export const LexicalChannelTextareaContent = observer( )} {renderSection( - - - + {!bare && ( + + + + )} { + ActiveComposer.focus(draftChannelId ?? channel.id); setIsFocused(true); setIsInputAreaFocused(true); ChannelSearch.setInputFocused(channel.id, false); @@ -1390,32 +1493,34 @@ export const LexicalChannelTextareaContent = observer( /> - 0} - expressionPickerTriggerRef={expressionPickerTriggerRef} - invisibleExpressionPickerTriggerRef={invisibleExpressionPickerTriggerRef} - onExpressionPickerToggle={handleExpressionPickerTabToggle} - onSubmit={handleSubmit} - channelId={channel.id} - data-flx="channel.lexical-channel-textarea-content.textarea-buttons.submit" - /> + {!bare && ( + 0} + expressionPickerTriggerRef={expressionPickerTriggerRef} + invisibleExpressionPickerTriggerRef={invisibleExpressionPickerTriggerRef} + onExpressionPickerToggle={handleExpressionPickerTabToggle} + onSubmit={handleSubmit} + channelId={channel.id} + data-flx="channel.lexical-channel-textarea-content.textarea-buttons.submit" + /> + )} , styles.inputSection, )} diff --git a/fluxer_app/src/features/channel/components/MessageActionBar.tsx b/fluxer_app/src/features/channel/components/MessageActionBar.tsx index 17626d6e8..242a413b6 100644 --- a/fluxer_app/src/features/channel/components/MessageActionBar.tsx +++ b/fluxer_app/src/features/channel/components/MessageActionBar.tsx @@ -34,10 +34,13 @@ import { import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {getEmojiNameWithColons, toReactionEmoji} from '@app/features/messaging/utils/ReactionUtils'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; +import {openCreateThread} from '@app/features/threads/commands/ThreadNavigation'; +import {CREATE_THREAD_DESCRIPTOR} from '@app/features/threads/utils/ThreadMessageDescriptors'; import { AddReactionIcon, CopyIdIcon, CopyLinkIcon, + CreateThreadIcon, CrosspostIcon, DebugMessageIcon, DeleteIcon, @@ -252,6 +255,7 @@ interface MessageActionBarCoreProps { canForwardMessage: boolean; canCrosspostMessage: boolean; shouldRenderSuppressEmbeds: boolean; + canCreateThread?: boolean; }; developerMode: boolean; isActive: boolean; @@ -647,6 +651,19 @@ export const MessageActionBarCore: React.FC = observe data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.crosspost" /> )} + {permissions.canCreateThread && ( + + } + label={i18n._(CREATE_THREAD_DESCRIPTOR)} + onClick={() => openCreateThread(channel, message.id)} + data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.create-thread" + /> + )} {showFullActions && canDeleteMessage && ( , + label: i18n._(CREATE_THREAD_DESCRIPTOR), + onClick: () => openCreateThread(permissions.channel, message.id), + }); + } if (message.isCurrentUserAuthor() && message.isUserMessage() && !message.messageSnapshots) { interactionActions.push({ id: messageActionMenuItemIds.edit, diff --git a/fluxer_app/src/features/channel/components/MessageActionUtils.tsx b/fluxer_app/src/features/channel/components/MessageActionUtils.tsx index bfe5678c7..bc8da4c19 100644 --- a/fluxer_app/src/features/channel/components/MessageActionUtils.tsx +++ b/fluxer_app/src/features/channel/components/MessageActionUtils.tsx @@ -30,6 +30,9 @@ import Permission from '@app/features/permissions/state/Permission'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands'; import Relationships from '@app/features/relationship/state/Relationships'; +import ChannelThreads from '@app/features/threads/state/ChannelThreads'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; +import {canCreateThreadIn, canWriteInThread} from '@app/features/threads/utils/ThreadActionRules'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; @@ -47,6 +50,7 @@ import { Permissions, } from '@fluxer/constants/src/ChannelConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; +import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; @@ -173,6 +177,13 @@ export interface MessagePermissions { canCrosspostMessage: boolean; canSuppressEmbeds: boolean; shouldRenderSuppressEmbeds: boolean; + canCreateThread: boolean; +} + +function canCreateThreadFromMessage(message: Message, channel: Channel): boolean { + if (!ThreadGuilds.isActive(channel.guildId) || !TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channel.type)) return false; + if (message.state !== MessageStates.SENT || !message.isUserMessage() || message.isClientSystemMessage()) return false; + return !ChannelThreads.hasThread(message.id) && canCreateThreadIn(channel, 'from_message'); } function canForwardMessageFromChannel(message: Message, channel: Channel, isDM: boolean): boolean { @@ -249,19 +260,21 @@ function getMessagePermissionsForChannel(message: Message, channel: Channel): Me (message.isCurrentUserAuthor() || (!isDM && canDeleteMessage)); const shouldRenderSuppressEmbeds = message.isUserMessage() && canSuppressEmbeds && (isEmbedsSuppressed(message) || message.embeds.length > 0); + const isThread = channel.isThread(); return { channel, isDM, canSendMessages, - canAddReactions, - canEditMessage, + canAddReactions: canAddReactions && (!isThread || canWriteInThread(channel, 'react')), + canEditMessage: canEditMessage && (!isThread || canWriteInThread(channel, 'edit')), canDeleteMessage, canDeleteAttachment, - canPinMessage, + canPinMessage: canPinMessage && (!isThread || canWriteInThread(channel, 'pin')), canForwardMessage, canCrosspostMessage, canSuppressEmbeds, shouldRenderSuppressEmbeds, + canCreateThread: canCreateThreadFromMessage(message, channel), }; } @@ -286,6 +299,7 @@ export function useMessagePermissions(message: Message, sourceChannel?: Channel if (context?.previewPermissions) { return { channel: channel ?? context.channel, + canCreateThread: false, ...context.previewPermissions, }; } diff --git a/fluxer_app/src/features/channel/components/MessageAttachments.tsx b/fluxer_app/src/features/channel/components/MessageAttachments.tsx index d67a5297b..2abe46657 100644 --- a/fluxer_app/src/features/channel/components/MessageAttachments.tsx +++ b/fluxer_app/src/features/channel/components/MessageAttachments.tsx @@ -40,6 +40,8 @@ import {canonicalizeMediaUrl, useSpoilerState} from '@app/features/messaging/uti import markupStyles from '@app/features/theme/styles/Markup.module.css'; import messageStyles from '@app/features/theme/styles/Message.module.css'; import * as ThemeUtils from '@app/features/theme/utils/ThemeUtils'; +import {MessageThreadChip} from '@app/features/threads/components/MessageThreadChip'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import {StickerInlineMenuItems} from '@app/features/ui/action_menu/items/StickerContextMenuItems'; import {MessageContextMenu} from '@app/features/ui/action_menu/MessageContextMenu'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; @@ -720,6 +722,9 @@ export const MessageAttachments = observer(() => { data-flx="channel.message-attachments.message-reactions" /> )} + {!isPreview && ThreadGuilds.anyActive && ( + + )} ); }); diff --git a/fluxer_app/src/features/channel/components/MessageAuthorInfo.tsx b/fluxer_app/src/features/channel/components/MessageAuthorInfo.tsx index 710d95352..aa699f133 100644 --- a/fluxer_app/src/features/channel/components/MessageAuthorInfo.tsx +++ b/fluxer_app/src/features/channel/components/MessageAuthorInfo.tsx @@ -5,6 +5,7 @@ import {MessageAvatar} from '@app/features/channel/components/MessageAvatar'; import {MessageTimeoutIndicator} from '@app/features/channel/components/MessageTimeoutIndicator'; import {MessageUsername} from '@app/features/channel/components/MessageUsername'; import {TimestampWithTooltip} from '@app/features/channel/components/TimestampWithTooltip'; +import {isOriginalPoster} from '@app/features/forum/utils/ForumChannelUtils'; import type {Guild} from '@app/features/guild/models/Guild'; import type {GuildMember} from '@app/features/member/models/GuildMember'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; @@ -111,6 +112,13 @@ export const MessageAuthorInfo = observer((props: MessageAuthorInfoProps) => { data-flx="channel.message-author-info.user-tag-offset" /> )} + {isOriginalPoster(message.channelId, author.id) && ( + + )} { data-flx="channel.user-message.user-tag-offset--2" /> )} + {isOriginalPoster(message.channelId, author.id) && ( + + )} { data-flx="channel.user-message.user-tag-offset--3" /> )} + {isOriginalPoster(message.channelId, author.id) && ( + + )} { +export const BarrierBase = observer(({message, action, icon}: BarrierBaseProps) => { const hasAction = Boolean(action); return (
data-flx="channel.channel-details-bottom-sheet.dm-members-list" /> )} - {isGuildChannel && guild && ( + {isGuildChannel && guild && channel.isThread() && ( + + )} + {isGuildChannel && guild && !channel.isThread() && ( ); } + if (channel.isThreadOnly()) { + return ( + + + + ); + } const voiceJoinEmptyState = isVoiceChannel ? ( ); } - const shouldRenderMemberList = isMemberListVisible && !isMobileLayout && !isSearchActive; - return ( + const shouldRenderMemberList = isMemberListVisible && !isMobileLayout && !isSearchActive && !threadPanelOpen; + const isThreadChannel = channel.isThread(); + const scaffold = ( } - textarea={renderChatArea()} + textarea={ + isThreadChannel && passesVerification ? ( + + ) : ( + renderChatArea() + ) + } data-flx="channel.channel-view.guild-channel-view.channel-chat-layout--2" /> } @@ -580,15 +603,27 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView />
) : shouldRenderMemberList ? ( - + isThreadChannel ? ( + + ) : ( + + ) ) : null } showMemberListDivider={shouldRenderMemberList && !isSearchActive} data-flx="channel.channel-view.guild-channel-view.channel-view-scaffold" /> ); + return ( + + {scaffold} + + ); }); diff --git a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx index ff39dc44a..6d3d52847 100644 --- a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx +++ b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx @@ -332,7 +332,7 @@ export const AttachmentGridItem: FC = observer( canFavorite, isPreview ? undefined : message, isRealAttachment ? attachment.id : undefined, - {disableDelete: !!isPreview || snapshotIndex !== undefined}, + {disableDelete: !!isPreview || snapshotIndex !== undefined, mediaType}, ); const gridItemStyle: CSSProperties = { ...style, diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedAudio.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedAudio.tsx index ad13870c4..7a0817fb0 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedAudio.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedAudio.tsx @@ -138,7 +138,7 @@ const EmbedAudio: FC = observer( canFavorite, isPreview ? undefined : message, attachmentId, - {disableDelete: !!isPreview || snapshotIndex !== undefined}, + {disableDelete: !!isPreview || snapshotIndex !== undefined, mediaType: 'audio'}, ); return (
diff --git a/fluxer_app/src/features/channel/components/embeds/media/MediaButtonUtils.ts b/fluxer_app/src/features/channel/components/embeds/media/MediaButtonUtils.ts index 09ca29642..ffffef2cf 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/MediaButtonUtils.ts +++ b/fluxer_app/src/features/channel/components/embeds/media/MediaButtonUtils.ts @@ -2,10 +2,12 @@ import Accessibility from '@app/features/accessibility/state/Accessibility'; import {canDeleteAttachmentUtil} from '@app/features/channel/components/MessageActionUtils'; +import {isMediaDownloadHidden} from '@app/features/forum/utils/MediaDownloadPolicy'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; export interface MediaButtonVisibilityOptions { disableDelete?: boolean; + mediaType?: string; } export interface MediaButtonVisibility { @@ -26,7 +28,8 @@ export function getMediaButtonVisibility( const disableDelete = options?.disableDelete ?? false; return { showFavoriteButton: showMediaFavoriteButton && canFavorite, - showDownloadButton: showMediaDownloadButton, + showDownloadButton: + showMediaDownloadButton && !isMediaDownloadHidden(message?.channelId, options?.mediaType ?? 'image'), showDeleteButton: showMediaDeleteButton && !disableDelete && !!(message && attachmentId && canDeleteAttachmentUtil(message)), }; diff --git a/fluxer_app/src/features/channel/components/modals/ChannelCreateModal.tsx b/fluxer_app/src/features/channel/components/modals/ChannelCreateModal.tsx index 577bb21a5..4070b78e4 100644 --- a/fluxer_app/src/features/channel/components/modals/ChannelCreateModal.tsx +++ b/fluxer_app/src/features/channel/components/modals/ChannelCreateModal.tsx @@ -5,12 +5,13 @@ import {EXAMPLE_CHANNEL_NAME, EXAMPLE_URL} from '@app/features/app/config/I18nDi import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit'; import styles from '@app/features/channel/components/modals/ChannelCreateModal.module.css'; import { - channelTypeOptions, createChannel, type FormInputs, + getChannelTypeOptions, getDefaultValues, } from '@app/features/channel/utils/ChannelCreateModalUtils'; import {CANCEL_DESCRIPTOR, CREATE_CHANNEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {Form} from '@app/features/ui/components/form/Form'; @@ -68,7 +69,7 @@ export const ChannelCreateModal = observer(({guildId, parentId}: {guildId: strin aria-label={i18n._(CHANNEL_TYPE_SELECTION_DESCRIPTOR)} value={Number(field.value)} onChange={(value) => field.onChange(value.toString())} - options={channelTypeOptions} + options={getChannelTypeOptions(i18n, {forums: ThreadGuilds.isActive(guildId)})} data-flx="channel.channel-create-modal.radio-group.change" /> )} diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx b/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx index 3f12523d2..33dcc6bf7 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx @@ -35,6 +35,8 @@ import { } from '@app/features/i18n/utils/CommonMessageDescriptors'; import Permission from '@app/features/permissions/state/Permission'; import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection'; +import {ThreadDefaultsSection} from '@app/features/threads/components/ThreadDefaultsSection'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; 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'; @@ -52,6 +54,10 @@ import { } from '@fluxer/constants/src/ChannelConstants'; import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants'; import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants'; +import { + DEFAULT_THREAD_AUTO_ARCHIVE_DURATION, + TEXT_THREAD_PARENT_CHANNEL_TYPES, +} from '@fluxer/constants/src/ThreadConstants'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; @@ -150,8 +156,19 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) rtc_region: null, }, }); + const threadDefaultsActive = + channel != null && + TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channel.type) && + ThreadGuilds.isActive(guildId) && + canManageChannel; const remoteValues: FormInputs | null = channel ? { + ...(threadDefaultsActive + ? { + default_auto_archive_duration: channel.defaultAutoArchiveDuration ?? DEFAULT_THREAD_AUTO_ARCHIVE_DURATION, + default_thread_rate_limit_per_user: channel.defaultThreadRateLimitPerUser, + } + : {}), name: channel.name || '', topic: channel.topic || '', url: channel.url || '', @@ -255,6 +272,14 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) } } } + if (threadDefaultsActive) { + if (dirty.default_auto_archive_duration) { + updateData.default_auto_archive_duration = data.default_auto_archive_duration; + } + if (dirty.default_thread_rate_limit_per_user) { + updateData.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user; + } + } if (channel.type === ChannelTypes.GUILD_VOICE && (canManageChannel || canUpdateRtcRegion) && dirty.rtc_region) { updateData.rtc_region = data.rtc_region ?? null; } @@ -266,7 +291,8 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) try { await ChannelCommands.update(channel.id, updateData); } catch (error) { - if (failureCode(error) === APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS) { + const code = failureCode(error); + if (code === APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS || code === APIErrorCodes.CHANNEL_HAS_THREADS) { showGenericErrorModal({ title: i18n._(COULD_NOT_CONVERT_CHANNEL_DESCRIPTOR), message: failureMessage(error) ?? i18n._(TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR), @@ -293,6 +319,12 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) currentValues.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT, rtc_region: data.rtc_region ?? currentValues.rtc_region ?? null, + ...(threadDefaultsActive + ? { + default_auto_archive_duration: data.default_auto_archive_duration, + default_thread_rate_limit_per_user: data.default_thread_rate_limit_per_user, + } + : {}), }); ToastCommands.createToast({type: 'success', children: Channel updated}); }; @@ -333,7 +365,16 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) } await persist(); }, - [canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues, maxBitrateKbps, i18n], + [ + canManageChannel, + canUpdateRtcRegion, + channel, + form, + commitRemoteValues, + maxBitrateKbps, + i18n, + threadDefaultsActive, + ], ); const {handleSubmit: handleSave} = useFormSubmit({ form, @@ -432,6 +473,17 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) )}
)} + {threadDefaultsActive && ( +
+ +
+ )} {showVoiceSection && (
{canManageChannel && ( diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelPermissionsTab.tsx b/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelPermissionsTab.tsx index ddf58e22a..1d6517dad 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelPermissionsTab.tsx +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelPermissionsTab.tsx @@ -22,6 +22,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers'; import Permission from '@app/features/permissions/state/Permission'; import PermissionLayout from '@app/features/permissions/state/PermissionLayout'; import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; 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'; @@ -34,6 +35,7 @@ import Users from '@app/features/user/state/Users'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import {flip, offset, shift, useClick, useDismiss, useFloating, useInteractions, useRole} from '@floating-ui/react'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {matchSorter} from 'match-sorter'; @@ -291,6 +293,7 @@ const ChannelPermissionsTab: React.FC<{channelId: string}> = observer(({channelI const handleSave = useCallback(async () => { if (!channel || !canManageChannels || !canManageRoles) return; try { + const retiredPermissions = ThreadGuilds.isActive(channel.guildId) ? 0n : THREAD_PERMISSIONS; const updatedOverwrites = overwritesWithUpdates .filter((ow) => !deletedOverwriteIds.has(ow.id)) .filter((ow) => { @@ -302,8 +305,8 @@ const ChannelPermissionsTab: React.FC<{channelId: string}> = observer(({channelI .map((ow): {id: string; type: 0 | 1; allow: string; deny: string} => ({ id: ow.id, type: ow.type, - allow: ow.allow.toString(), - deny: ow.deny.toString(), + allow: (ow.allow & ~retiredPermissions).toString(), + deny: (ow.deny & ~retiredPermissions).toString(), })); await ChannelCommands.updatePermissionOverwrites(channel.id, updatedOverwrites); setOverwriteUpdates(new Map()); @@ -402,10 +405,11 @@ const ChannelPermissionsTab: React.FC<{channelId: string}> = observer(({channelI }, [selectedOverwrite, handleOverwriteUpdate, applyPermissionState], ); + const threadsActive = ThreadGuilds.isActive(channel?.guildId); const permissionSpecs = useMemo(() => { if (!channel) return []; - return PermissionUtils.generateChannelPermissionSpecs(i18n, channel.type); - }, [channel, i18n.locale]); + return PermissionUtils.generateChannelPermissionSpecs(i18n, channel.type, {threads: threadsActive}); + }, [channel, i18n.locale, threadsActive]); const filteredPermissionSpecs = useMemo(() => { if (!permissionSearchQuery) return permissionSpecs; return permissionSpecs diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx index 3785c4113..7a787c8ca 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx @@ -65,15 +65,17 @@ function formatSlowmodeDuration(i18n: I18n, seconds: number): string { interface SlowmodeControlProps { form: UseFormReturn; + name?: 'slowmode' | 'default_thread_rate_limit_per_user'; + label?: string; } -export const SlowmodeControl: React.FC = ({form}) => { +export const SlowmodeControl: React.FC = ({form, name = 'slowmode', label}) => { const {i18n} = useLingui(); - const slowmodeLabel = i18n._(SLOWMODE_DESCRIPTOR); + const slowmodeLabel = label ?? i18n._(SLOWMODE_DESCRIPTOR); const bypassSlowmodePermissionLabel = formatPermissionLabel(i18n, Permissions.BYPASS_SLOWMODE); return ( { let currentSeconds: number; diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/shared.ts b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/shared.ts index cdca4e053..5612c6a6c 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/shared.ts +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/shared.ts @@ -18,6 +18,8 @@ export interface FormInputs { user_limit?: number; voice_connection_limit?: number; rtc_region: string | null; + default_auto_archive_duration?: number; + default_thread_rate_limit_per_user?: number; } export const CHANNEL_OVERVIEW_TAB_ID = 'overview'; diff --git a/fluxer_app/src/features/channel/components/textarea/TextareaInput.module.css b/fluxer_app/src/features/channel/components/textarea/TextareaInput.module.css index 5ac15f618..76b6a36e9 100644 --- a/fluxer_app/src/features/channel/components/textarea/TextareaInput.module.css +++ b/fluxer_app/src/features/channel/components/textarea/TextareaInput.module.css @@ -224,3 +224,21 @@ .textareaOuterRow:focus-within { --composer-box-ring-color: var(--floating-surface-ring-color-strong); } + +.textareaOuterBare { + --composer-box-padding-inline: 0px; + --textarea-padding-y: 0px; + --textarea-min-height: 1.5rem; + padding-inline: 0; + background-color: transparent; +} + +.mainWrapperBare { + grid-template-columns: minmax(0, 1fr); + column-gap: 0; +} + +.mainWrapperBare > .contentAreaDense { + grid-column: 1; + padding-top: 0; +} diff --git a/fluxer_app/src/features/channel/components/textarea/TextareaPlusMenu.tsx b/fluxer_app/src/features/channel/components/textarea/TextareaPlusMenu.tsx index a98ff4d57..c32bc926a 100644 --- a/fluxer_app/src/features/channel/components/textarea/TextareaPlusMenu.tsx +++ b/fluxer_app/src/features/channel/components/textarea/TextareaPlusMenu.tsx @@ -6,7 +6,9 @@ import {CANNOT_SEND_MESSAGES_IN_CHANNEL_DESCRIPTOR} from '@app/features/i18n/uti import Keybind from '@app/features/input/state/InputKeybind'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; import {useCanSendGift} from '@app/features/premium/hooks/useCanSendGift'; +import {CREATE_THREAD_DESCRIPTOR} from '@app/features/threads/utils/ThreadMessageDescriptors'; import {CheckboxItem} from '@app/features/ui/action_menu/ContextMenu'; +import {CreateThreadIcon} from '@app/features/ui/action_menu/ContextMenuIcons'; import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu'; @@ -70,6 +72,7 @@ interface TextareaPlusMenuProps { textareaValue?: string; onUploadAsFile?: () => void; onSendVoiceMessage?: () => void; + onCreateThread?: () => void; } export const TextareaPlusMenu = observer( @@ -80,6 +83,7 @@ export const TextareaPlusMenu = observer( textareaValue, onUploadAsFile, onSendVoiceMessage, + onCreateThread, }: TextareaPlusMenuProps) => { const {i18n} = useLingui(); const showGifButton = Accessibility.showGifButton; @@ -133,6 +137,15 @@ export const TextareaPlusMenu = observer( {i18n._(UPLOAD_YOUR_MESSAGE_AS_A_FILE_DESCRIPTOR)} )} + {onCreateThread && ( + } + onClick={onCreateThread} + data-flx="channel.textarea.textarea-plus-menu.menu-item.create-thread" + > + {i18n._(CREATE_THREAD_DESCRIPTOR)} + + )} {canSendGift && ( } diff --git a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx index 7738ee0a1..5f250858b 100644 --- a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx +++ b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx @@ -36,6 +36,7 @@ import {useCallback, useEffect} from 'react'; interface UseChannelComposerPasteParams { channel: Channel; + attachmentKey: string; handleRef: React.RefObject; editableRef: React.RefObject; isFocused: boolean; @@ -47,6 +48,7 @@ interface UseChannelComposerPasteParams { export function useChannelComposerPaste({ channel, + attachmentKey, handleRef, editableRef, isFocused, @@ -66,6 +68,7 @@ export function useChannelComposerPaste({ pastedText, uploadAttachments.length, maxAttachments, + attachmentKey, ); if (!result.success) { if (result.error === 'too_many_attachments') { @@ -86,7 +89,7 @@ export function useChannelComposerPaste({ } } }, - [channel, uploadAttachments.length, maxAttachments], + [channel, attachmentKey, uploadAttachments.length, maxAttachments], ); const handlePasteFiles = useCallback( async (files: Array) => { @@ -99,6 +102,7 @@ export function useChannelComposerPaste({ files, uploadAttachments.length, maxAttachments, + attachmentKey, ); if (!result.success) { if (result.error === 'too_many_attachments') { @@ -119,7 +123,7 @@ export function useChannelComposerPaste({ } } }, - [channel, uploadAttachments.length, maxAttachments], + [channel, attachmentKey, uploadAttachments.length, maxAttachments], ); const insertPastedText = useCallback( (pastedText: string): boolean => { diff --git a/fluxer_app/src/features/channel/events/ChannelDelete.ts b/fluxer_app/src/features/channel/events/ChannelDelete.ts index 50985d8ba..1988b768c 100644 --- a/fluxer_app/src/features/channel/events/ChannelDelete.ts +++ b/fluxer_app/src/features/channel/events/ChannelDelete.ts @@ -14,6 +14,7 @@ import Permission from '@app/features/permissions/state/Permission'; import ReadStates from '@app/features/read_state/state/ReadStates'; import QuickSwitcher from '@app/features/search/state/QuickSwitcher'; import Slowmode from '@app/features/slowmode/state/Slowmode'; +import ChannelThreads from '@app/features/threads/state/ChannelThreads'; import * as PiPCommands from '@app/features/ui/commands/PiPCommands'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; import Webhooks from '@app/features/webhook/state/Webhooks'; @@ -25,23 +26,27 @@ interface ChannelDeletePayload { guild_id?: string; } -export function handleChannelDelete(data: ChannelDeletePayload, _context: GatewayHandlerContext): void { - const channel = data as Channel; - const guildId = data.guild_id; - PiPCommands.clearPiPForChannel(data.id); - MediaEngine.handleChannelDelete(data.id); - Slowmode.deleteChannel(data.id); - Drafts.deleteChannelDraft(data.id); +export function cleanupChannelLocalState(channel: Channel): void { + const guildId = channel.guild_id; + PiPCommands.clearPiPForChannel(channel.id); + MediaEngine.handleChannelDelete(channel.id); + Slowmode.deleteChannel(channel.id); + Drafts.deleteChannelDraft(channel.id); SavedMessages.handleChannelDelete(channel); ChannelPins.handleChannelDelete(channel); Channels.handleChannelDelete({channel}); - Permission.handleChannelDelete(data.id, guildId); - GuildReadState.handleChannelDelete(data.id); - Invites.handleChannelDelete(data.id); - Webhooks.handleChannelDelete(data.id); + Permission.handleChannelDelete(channel.id, guildId); + GuildReadState.handleChannelDelete(channel.id); + Invites.handleChannelDelete(channel.id); + Webhooks.handleChannelDelete(channel.id); ReadStates.handleChannelDelete({channel}); SelectedChannel.handleChannelDelete(channel); - Messages.handleCleanup(); MentionFeed.handleChannelDelete(channel); +} + +export function handleChannelDelete(data: ChannelDeletePayload, _context: GatewayHandlerContext): void { + ChannelThreads.handleParentDelete(data.id); + cleanupChannelLocalState(data as Channel); + Messages.handleCleanup(); QuickSwitcher.recomputeIfOpen(); } diff --git a/fluxer_app/src/features/channel/models/Channel.ts b/fluxer_app/src/features/channel/models/Channel.ts index 8e7b23b15..e57d2efc1 100644 --- a/fluxer_app/src/features/channel/models/Channel.ts +++ b/fluxer_app/src/features/channel/models/Channel.ts @@ -6,7 +6,14 @@ import UserPinnedDM from '@app/features/user/state/UserPinnedDM'; import Users from '@app/features/user/state/Users'; import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants'; +import {THREAD_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import type {ChannelOverwrite, Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import type { + DefaultReactionEmojiResponse, + ForumTagResponse, + ThreadMemberResponse, + ThreadMetadataResponse, +} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; @@ -50,6 +57,68 @@ interface ChannelRecordOptions { instanceId?: string; } +export interface ThreadChannelFields { + readonly flags?: number; + readonly thread_metadata?: ThreadMetadataResponse; + readonly applied_tags?: ReadonlyArray; + readonly message_count?: number; + readonly total_message_sent?: number; + readonly member_count?: number; + readonly default_auto_archive_duration?: number | null; + readonly default_thread_rate_limit_per_user?: number; + readonly available_tags?: ReadonlyArray; + readonly default_reaction_emoji?: DefaultReactionEmojiResponse | null; + readonly default_sort_order?: number | null; + readonly default_forum_layout?: number; + readonly default_tag_setting?: 'match_some' | 'match_all'; +} + +export interface ChannelWire extends WireChannel, ThreadChannelFields { + readonly member?: ThreadMemberResponse; + readonly newly_created?: boolean; +} + +const THREAD_FIELD_KEYS: ReadonlyArray = [ + 'flags', + 'thread_metadata', + 'applied_tags', + 'message_count', + 'total_message_sent', + 'member_count', + 'default_auto_archive_duration', + 'default_thread_rate_limit_per_user', + 'available_tags', + 'default_reaction_emoji', + 'default_sort_order', + 'default_forum_layout', + 'default_tag_setting', +]; + +function pickThreadFields(source: ThreadChannelFields, base?: ThreadChannelFields | null): ThreadChannelFields | null { + let picked: Record | null = base ? {...base} : null; + for (const key of THREAD_FIELD_KEYS) { + const value = source[key]; + if (value !== undefined) { + picked ??= {}; + picked[key] = value; + } + } + return picked as ThreadChannelFields | null; +} + +function threadFieldsEqual(a: ThreadChannelFields | null, b: ThreadChannelFields | null): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + for (const key of THREAD_FIELD_KEYS) { + const left = a[key]; + const right = b[key]; + if (left === right) continue; + if (typeof left !== 'object' || typeof right !== 'object' || left == null || right == null) return false; + if (JSON.stringify(left) !== JSON.stringify(right)) return false; + } + return true; +} + function getRecipientPartials(recipientIds: ReadonlyArray): Array { return recipientIds .map((id) => Users?.getUser(id)?.toJSON()) @@ -82,8 +151,9 @@ export class Channel { readonly contentWarningText: string | null; readonly rateLimitPerUser: number; readonly nicks: Readonly>; + readonly threadFields: ThreadChannelFields | null; - constructor(channel: WireChannel, options?: ChannelRecordOptions) { + constructor(channel: ChannelWire, options?: ChannelRecordOptions) { this.instanceId = options?.instanceId ?? RuntimeConfig.localInstanceDomain; this.id = channel.id; this.guildId = channel.guild_id; @@ -110,6 +180,7 @@ export class Channel { this.contentWarningText = channel.content_warning_text ?? null; this.rateLimitPerUser = channel.rate_limit_per_user ?? 0; this.nicks = channel.nicks ?? {}; + this.threadFields = pickThreadFields(channel); if ((this.type === ChannelTypes.DM || this.type === ChannelTypes.GROUP_DM) && channel.recipients) { Users?.cacheUsers(Array.from(channel.recipients)); } @@ -184,6 +255,62 @@ export class Channel { return this.nsfw; } + isThread(): boolean { + return THREAD_CHANNEL_TYPES.has(this.type); + } + + isPrivateThread(): boolean { + return this.type === ChannelTypes.PRIVATE_THREAD; + } + + isThreadOnly(): boolean { + return THREAD_ONLY_CHANNEL_TYPES.has(this.type); + } + + get flags(): number { + return this.threadFields?.flags ?? 0; + } + + get threadMetadata(): ThreadMetadataResponse | null { + return this.threadFields?.thread_metadata ?? null; + } + + get isArchived(): boolean { + return this.threadFields?.thread_metadata?.archived ?? false; + } + + get isLocked(): boolean { + return this.threadFields?.thread_metadata?.locked ?? false; + } + + get messageCount(): number { + return this.threadFields?.message_count ?? 0; + } + + get totalMessageSent(): number { + return this.threadFields?.total_message_sent ?? 0; + } + + get memberCount(): number { + return this.threadFields?.member_count ?? 0; + } + + get appliedTags(): ReadonlyArray { + return this.threadFields?.applied_tags ?? []; + } + + get availableTags(): ReadonlyArray { + return this.threadFields?.available_tags ?? []; + } + + get defaultAutoArchiveDuration(): number | null { + return this.threadFields?.default_auto_archive_duration ?? null; + } + + get defaultThreadRateLimitPerUser(): number { + return this.threadFields?.default_thread_rate_limit_per_user ?? 0; + } + isRoleRequired(): boolean { if ( this.guildId == null || @@ -208,7 +335,7 @@ export class Channel { return new Date(SnowflakeUtils.extractTimestamp(this.id)); } - withUpdates(updates: Partial): Channel { + withUpdates(updates: Partial): Channel { let newRecipients: Array = []; if ( updates.type === ChannelTypes.DM_PERSONAL_NOTES || @@ -255,6 +382,7 @@ export class Channel { updates.content_warning_text !== undefined ? updates.content_warning_text : this.contentWarningText, rate_limit_per_user: updates.rate_limit_per_user ?? this.rateLimitPerUser, nicks: updates.nicks ?? this.nicks, + ...pickThreadFields(updates, this.threadFields), }, {instanceId: this.instanceId}, ); @@ -300,6 +428,7 @@ export class Channel { if (this.contentWarningLevel !== other.contentWarningLevel) return false; if (this.contentWarningText !== other.contentWarningText) return false; if (this.rateLimitPerUser !== other.rateLimitPerUser) return false; + if (!threadFieldsEqual(this.threadFields, other.threadFields)) return false; if (this.recipientIds.length !== other.recipientIds.length) return false; for (let i = 0; i < this.recipientIds.length; i++) { if (this.recipientIds[i] !== other.recipientIds[i]) return false; @@ -316,7 +445,7 @@ export class Channel { return true; } - toJSON(): WireChannel { + toJSON(): ChannelWire { return { id: this.id, guild_id: this.guildId, @@ -345,6 +474,7 @@ export class Channel { content_warning_text: this.contentWarningText, rate_limit_per_user: this.rateLimitPerUser, nicks: this.nicks, + ...this.threadFields, }; } } diff --git a/fluxer_app/src/features/channel/state/ChannelFrecency.test.ts b/fluxer_app/src/features/channel/state/ChannelFrecency.test.ts new file mode 100644 index 000000000..e50507c1a --- /dev/null +++ b/fluxer_app/src/features/channel/state/ChannelFrecency.test.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Channel} from '@app/features/channel/models/Channel'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {observable, runInAction} from 'mobx'; +import {describe, expect, it, vi} from 'vitest'; + +const GUILD = '1500000000000000000'; +const CONTROL_GUILD = '1600000000000000000'; +const channels = observable.map(); +const navigation = observable({guildId: null as string | null, channelId: null as string | null}); +const mediaEngine = observable({guildId: null as string | null, channelId: null as string | null}); +const initialization = observable({hasCompletedInitialLoad: true}); +const activeGuildIds = observable.set([GUILD]); + +vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}})); +vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined, cacheUsers: () => {}}})); +vi.mock('@app/features/channel/state/Channels', () => ({ + default: {getChannel: (id: string) => channels.get(id), getPrivateChannels: () => []}, +})); +vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => undefined}})); +vi.mock('@app/features/threads/state/ThreadGuilds', () => ({ + default: { + isActive: (id: string | null) => id !== null && activeGuildIds.has(id), + get guildIds() { + return Array.from(activeGuildIds); + }, + }, +})); +vi.mock('@app/features/app/state/Initialization', () => ({default: initialization})); +vi.mock('@app/features/navigation/state/Navigation', () => ({default: navigation})); +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: mediaEngine})); +vi.mock('@app/features/user/state/SyncedField', () => ({makeSyncedField: async () => {}})); +vi.mock('@app/features/platform/utils/MobXPersistence', () => ({makePersistent: async () => {}})); + +const {default: ChannelFrecency} = await import('@app/features/channel/state/ChannelFrecency'); +await new Promise((resolve) => setTimeout(resolve, 0)); + +const TEXT = '1500000000000000001'; +const THREAD = '1500000000000000010'; + +function load(id: string, type: number, parentId?: string, guildId = GUILD): void { + runInAction(() => channels.set(id, new Channel({id, guild_id: guildId, type, parent_id: parentId}))); +} + +function select(channelId: string, guildId = GUILD): void { + runInAction(() => { + navigation.guildId = guildId; + navigation.channelId = channelId; + }); +} + +describe('channel frecency selection', () => { + it('never records a thread id selected before its channel loads', () => { + select(THREAD); + expect(ChannelFrecency.useLog.has(THREAD)).toBe(false); + load(TEXT, ChannelTypes.GUILD_TEXT); + load(THREAD, ChannelTypes.PUBLIC_THREAD, TEXT); + expect(ChannelFrecency.useLog.has(THREAD)).toBe(false); + expect(ChannelFrecency.localUseLog.has(THREAD)).toBe(true); + expect(ChannelFrecency.scoreFor(THREAD)).toBeGreaterThan(0); + expect(ChannelFrecency.frequentIds).toContain(THREAD); + expect(ChannelFrecency.useLog.has(GUILD)).toBe(true); + expect(ChannelFrecency.localUseLog.has(GUILD)).toBe(false); + }); + + it('records a guild channel once it loads after selection', () => { + runInAction(() => channels.delete(TEXT)); + select(TEXT); + expect(ChannelFrecency.useLog.has(TEXT)).toBe(false); + load(TEXT, ChannelTypes.GUILD_TEXT); + expect(ChannelFrecency.useLog.has(TEXT)).toBe(true); + }); + + it('records an unknown channel immediately outside thread guilds', () => { + const unknown = '1600000000000000001'; + select(unknown, CONTROL_GUILD); + expect(ChannelFrecency.useLog.has(unknown)).toBe(true); + }); + + it('defers an unknown channel selected before READY in a remembered thread guild', () => { + const lateGuild = '1700000000000000000'; + const lateParent = '1700000000000000001'; + const lateThread = '1700000000000000010'; + runInAction(() => { + activeGuildIds.add(lateGuild); + }); + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(lateThread, lateGuild); + expect(ChannelFrecency.useLog.has(lateThread)).toBe(false); + runInAction(() => { + initialization.hasCompletedInitialLoad = true; + }); + expect(ChannelFrecency.useLog.has(lateThread)).toBe(false); + load(lateParent, ChannelTypes.GUILD_TEXT, undefined, lateGuild); + load(lateThread, ChannelTypes.PUBLIC_THREAD, lateParent, lateGuild); + expect(ChannelFrecency.useLog.has(lateThread)).toBe(false); + expect(ChannelFrecency.localUseLog.has(lateThread)).toBe(true); + }); + + it('records a control channel selected before READY synchronously', () => { + const controlChannel = '1600000000000000002'; + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(controlChannel, CONTROL_GUILD); + expect(ChannelFrecency.useLog.get(controlChannel)?.hitCount).toBe(1); + runInAction(() => { + initialization.hasCompletedInitialLoad = true; + }); + expect(ChannelFrecency.useLog.get(controlChannel)?.hitCount).toBe(1); + }); + + it('records a deferred selection once READY leaves its guild without threads', () => { + const formerGuild = '1800000000000000000'; + const channel = '1800000000000000001'; + runInAction(() => { + activeGuildIds.add(formerGuild); + }); + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(channel, formerGuild); + expect(ChannelFrecency.useLog.has(channel)).toBe(false); + load(channel, ChannelTypes.GUILD_TEXT, undefined, formerGuild); + runInAction(() => { + activeGuildIds.delete(formerGuild); + initialization.hasCompletedInitialLoad = true; + }); + expect(ChannelFrecency.useLog.get(channel)?.hitCount).toBe(1); + }); + + it('drops a deferred selection that stays unknown once READY leaves its guild without threads', () => { + const killedGuild = '1900000000000000000'; + const purgedThread = '1900000000000000010'; + runInAction(() => { + activeGuildIds.add(killedGuild); + }); + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(purgedThread, killedGuild); + runInAction(() => { + activeGuildIds.delete(killedGuild); + initialization.hasCompletedInitialLoad = true; + }); + expect(ChannelFrecency.useLog.has(purgedThread)).toBe(false); + expect(ChannelFrecency.localUseLog.has(purgedThread)).toBe(false); + }); + + it('records an unknown control channel selected before READY synchronously', () => { + const controlChannel = '1600000000000000003'; + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(controlChannel, CONTROL_GUILD); + expect(ChannelFrecency.useLog.get(controlChannel)?.hitCount).toBe(1); + runInAction(() => { + initialization.hasCompletedInitialLoad = true; + }); + expect(ChannelFrecency.useLog.get(controlChannel)?.hitCount).toBe(1); + }); + + it('drops deferred selections on logout', () => { + const queued = '1500000000000000021'; + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(queued); + expect(ChannelFrecency.useLog.has(queued)).toBe(false); + ChannelFrecency.handleLogout(); + select(TEXT); + runInAction(() => { + activeGuildIds.delete(GUILD); + initialization.hasCompletedInitialLoad = true; + }); + expect(ChannelFrecency.useLog.has(queued)).toBe(false); + runInAction(() => { + activeGuildIds.add(GUILD); + }); + }); + + it('records the channel queued before logout when the next session selects it', () => { + const queued = '1500000000000000022'; + runInAction(() => { + initialization.hasCompletedInitialLoad = false; + }); + select(queued); + ChannelFrecency.handleLogout(); + runInAction(() => { + initialization.hasCompletedInitialLoad = true; + }); + load(queued, ChannelTypes.GUILD_TEXT); + select(queued); + expect(ChannelFrecency.useLog.get(queued)?.hitCount).toBe(1); + }); +}); diff --git a/fluxer_app/src/features/channel/state/ChannelFrecency.ts b/fluxer_app/src/features/channel/state/ChannelFrecency.ts index 28a4f8d26..fd1d84889 100644 --- a/fluxer_app/src/features/channel/state/ChannelFrecency.ts +++ b/fluxer_app/src/features/channel/state/ChannelFrecency.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import Initialization from '@app/features/app/state/Initialization'; import Channels from '@app/features/channel/state/Channels'; import { type ChannelFrecencyEntry, @@ -14,6 +15,10 @@ import { } from '@app/features/channel/utils/ChannelFrecencyCalculator'; import Guilds from '@app/features/guild/state/Guilds'; import Navigation from '@app/features/navigation/state/Navigation'; +import AppStorage from '@app/features/platform/state/PersistentStorage'; +import {makePersistent} from '@app/features/platform/utils/MobXPersistence'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; +import {isSyncExcludedChannelId} from '@app/features/threads/utils/SyncedPreferenceGuard'; import {makeSyncedField} from '@app/features/user/state/SyncedField'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; import {ME} from '@fluxer/constants/src/AppConstants'; @@ -24,14 +29,43 @@ import {compareShallow, isObservableMap, makeAutoObservable, observableShallow, const TRACKABLE_ID_PATTERN = /^\d{17,19}$/; const FRECENCY_REFRESH_INTERVAL_MS = 3_600_000; const FRECENCY_SYNC_DEBOUNCE_MS = 1_500; +const LOCAL_STORAGE_KEY = 'ChannelFrecencyLocal'; +const THREAD_GUILDS_STORAGE_KEY = 'ChannelFrecencyThreadGuilds'; let lastChannelId: string | null = null; let lastGuildId: string | null = null; +let localPersistence: Promise | null = null; +let deferredSelections: Array<{guildId: string | null; channelId: string; timestamp: number}> = []; +let rememberedThreadGuildIds: ReadonlySet = loadRememberedThreadGuildIds(); + +function loadRememberedThreadGuildIds(): ReadonlySet { + const stored = AppStorage.getJSON(THREAD_GUILDS_STORAGE_KEY); + return new Set(Array.isArray(stored) ? stored.filter((id): id is string => typeof id === 'string') : []); +} + +function rememberThreadGuildIds(guildIds: ReadonlyArray): void { + if (guildIds.length === rememberedThreadGuildIds.size && guildIds.every((id) => rememberedThreadGuildIds.has(id))) { + return; + } + rememberedThreadGuildIds = new Set(guildIds); + if (guildIds.length === 0) AppStorage.removeItem(THREAD_GUILDS_STORAGE_KEY); + else AppStorage.setJSON(THREAD_GUILDS_STORAGE_KEY, guildIds); +} function isTrackableId(id: string | null): id is string { return id !== null && TRACKABLE_ID_PATTERN.test(id); } +function isChannelTypePending(guildId: string | null, channelId: string | null): boolean { + return ( + guildId !== null && + guildId !== ME && + isTrackableId(channelId) && + (Initialization.hasCompletedInitialLoad ? ThreadGuilds.isActive(guildId) : rememberedThreadGuildIds.has(guildId)) && + Channels.getChannel(channelId) === undefined + ); +} + function findDMChannelIdForRecipient(userId: string): string | null { for (const channel of Channels.getPrivateChannels()) { if (channel.type === ChannelTypes.DM && channel.recipientIds.includes(userId)) { @@ -47,13 +81,15 @@ function resolveFrecencyRecordId(key: string): string | null { class ChannelFrecency { useLog = new Map(); + localUseLog = new Map(); constructor() { - makeAutoObservable(this, {useLog: observableShallow}, {autoBind: true}); + makeAutoObservable(this, {useLog: observableShallow, localUseLog: observableShallow}, {autoBind: true}); void this.initPersistence(); } private async initPersistence(): Promise { + if (AppStorage.getItem(LOCAL_STORAGE_KEY) != null) this.persistLocal(); await makeSyncedField(this, { field: 'channelFrecency', schema: ChannelFrecencyStateSchema, @@ -67,13 +103,32 @@ class ChannelFrecency { usage: mergeChannelFrecencyWireUsage(local.usage, incoming.usage, Date.now()), }), }); + await localPersistence; this.refreshHistory(); setInterval(this.refreshHistory, FRECENCY_REFRESH_INTERVAL_MS); reaction( - () => [Navigation.guildId, Navigation.channelId] as const, + () => + [ + Navigation.guildId, + Navigation.channelId, + isChannelTypePending(Navigation.guildId, Navigation.channelId), + ] as const, ([guildId, channelId]) => this.recordSelection(guildId, channelId), {equals: compareShallow, fireImmediately: true}, ); + reaction( + () => Initialization.hasCompletedInitialLoad, + (loaded) => { + if (loaded) this.flushDeferredSelections(); + }, + ); + reaction( + () => (Initialization.hasCompletedInitialLoad ? ThreadGuilds.guildIds : null), + (guildIds) => { + if (guildIds !== null) rememberThreadGuildIds(guildIds); + }, + {equals: compareShallow, fireImmediately: true}, + ); reaction( () => [MediaEngine.guildId, MediaEngine.channelId] as const, ([guildId, channelId]) => { @@ -85,25 +140,35 @@ class ChannelFrecency { } get frequentIds(): ReadonlyArray { - return rankFrequentChannelIds(this.useLog, resolveFrecencyRecordId); + const history = this.localUseLog.size === 0 ? this.useLog : new Map([...this.useLog, ...this.localUseLog]); + return rankFrequentChannelIds(history, resolveFrecencyRecordId); } scoreFor(id: string): number { - return this.useLog.get(id)?.heat ?? 0; + return this.useLog.get(id)?.heat ?? this.localUseLog.get(id)?.heat ?? 0; } recordUse(key: string, timestamp?: number): void { - trackChannelUse(this.useLog, key, timestamp); - capChannelFrecencyHistory(this.useLog); - computeChannelFrecency(this.useLog, Date.now()); + const local = isSyncExcludedChannelId(key); + if (local) this.persistLocal(); + const history = local ? this.localUseLog : this.useLog; + trackChannelUse(history, key, timestamp); + capChannelFrecencyHistory(history); + computeChannelFrecency(history, Date.now()); } recordSelection(guildId: string | null, channelId: string | null): void { const selectedGuildId = guildId === ME ? null : guildId; + if (Initialization.hasCompletedInitialLoad) this.flushDeferredSelections(); if (channelId !== lastChannelId) { - lastChannelId = channelId; - if (isTrackableId(channelId)) { - this.recordUse(channelId); + if (!isChannelTypePending(guildId, channelId)) { + lastChannelId = channelId; + if (isTrackableId(channelId)) { + this.recordUse(channelId); + } + } else if (!Initialization.hasCompletedInitialLoad && channelId !== null) { + lastChannelId = channelId; + deferredSelections.push({guildId, channelId, timestamp: Date.now()}); } } if (selectedGuildId !== lastGuildId) { @@ -114,10 +179,39 @@ class ChannelFrecency { } } + handleLogout(): void { + if (deferredSelections.at(-1)?.channelId === lastChannelId) lastChannelId = null; + deferredSelections = []; + } + + private flushDeferredSelections(): void { + if (deferredSelections.length === 0) return; + const selections = deferredSelections; + deferredSelections = []; + const last = selections.at(-1); + for (const selection of selections) { + if (Channels.getChannel(selection.channelId) !== undefined) { + this.recordUse(selection.channelId, selection.timestamp); + } else if ( + selection === last && + selection.channelId === lastChannelId && + isChannelTypePending(selection.guildId, selection.channelId) + ) { + lastChannelId = null; + } + } + } + + private persistLocal(): void { + localPersistence ??= makePersistent(this, LOCAL_STORAGE_KEY, ['localUseLog']); + } + private refreshHistory(): void { + const now = Date.now(); const current: unknown = this.useLog; - if (!isObservableMap(current)) return; - this.useLog = restoreChannelFrecencyHistory(current.entries(), Date.now()); + if (isObservableMap(current)) this.useLog = restoreChannelFrecencyHistory(current.entries(), now); + const local: unknown = this.localUseLog; + if (isObservableMap(local)) this.localUseLog = restoreChannelFrecencyHistory(local.entries(), now); } } diff --git a/fluxer_app/src/features/channel/state/Channels.ts b/fluxer_app/src/features/channel/state/Channels.ts index b3d25a540..d91be2209 100644 --- a/fluxer_app/src/features/channel/state/Channels.ts +++ b/fluxer_app/src/features/channel/state/Channels.ts @@ -2,16 +2,23 @@ import {Routes} from '@app/app/Routes'; import Authentication from '@app/features/auth/state/Authentication'; -import {Channel} from '@app/features/channel/models/Channel'; +import {Channel, type ChannelWire} from '@app/features/channel/models/Channel'; import ChannelDisplayName from '@app/features/channel/state/ChannelDisplayName'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; import type {GuildReadyData} from '@app/features/gateway/types/GatewayGuildTypes'; import {filterViewableChannels} from '@app/features/messaging/utils/ChannelShared'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; import * as RouterUtils from '@app/features/navigation/utils/RouterUtils'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import Users from '@app/features/user/state/Users'; import {ME} from '@fluxer/constants/src/AppConstants'; -import {ChannelTypes, TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; +import { + ChannelTypes, + MessageStates, + MessageTypes, + TEXT_BASED_CHANNEL_TYPES, +} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_CHANNEL_TYPES, THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; @@ -137,8 +144,11 @@ class Channels { ChannelDisplayName.removeChannel(channelId); } - private setChannel(channel: Channel | WireChannel): void { + private setChannel(channel: Channel | ChannelWire): void { const record = channel instanceof Channel ? channel : new Channel(channel); + if (THREAD_FEATURE_CHANNEL_TYPES.has(record.type) && !ThreadGuilds.isActive(record.guildId)) { + return; + } const existing = this.channelsById.get(record.id); if (existing && existing !== record && existing.equals(record)) { return; @@ -160,6 +170,9 @@ class Channels { } private addChannelToIndex(channel: Channel): void { + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + return; + } if (channel.guildId) { const list = this.channelsByGuildId.get(channel.guildId) ?? EMPTY_CHANNELS; this.channelsByGuildId.set(channel.guildId, insertSorted(list, channel)); @@ -171,6 +184,9 @@ class Channels { } private removeChannelFromIndex(channel: Channel): void { + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + return; + } if (channel.guildId) { const list = this.channelsByGuildId.get(channel.guildId); if (!list) { @@ -196,6 +212,9 @@ class Channels { } private replaceChannelInIndex(previous: Channel, next: Channel): void { + if (THREAD_CHANNEL_TYPES.has(next.type)) { + return; + } if (next.guildId) { const list = this.channelsByGuildId.get(next.guildId); const index = list ? list.findIndex((entry) => entry.id === next.id) : -1; @@ -285,15 +304,39 @@ class Channels { } } - handleChannelCreate({channel}: {channel: WireChannel}): void { + handleChannelCreate({channel}: {channel: ChannelWire}): void { this.setChannel(channel); } + upsertThread(channel: ChannelWire): void { + this.setChannel(channel); + } + + removeThread(channelId: string): void { + this.removeChannel(channelId); + } + + handleThreadMessageDelete(channelId: string, messageId: string): void { + this.handleThreadMessageDeleteBulk(channelId, [messageId]); + } + + handleThreadMessageDeleteBulk(channelId: string, messageIds: ReadonlyArray): void { + const channel = this.channelsById.get(channelId); + if (!channel?.isThread() || channel.messageCount === 0) return; + const counted = messageIds.filter((id) => id !== channelId).length; + if (counted === 0) return; + this.setChannel(channel.withUpdates({message_count: Math.max(0, channel.messageCount - counted)})); + } + handlePassiveLastMessageUpdates({guildId, channels}: {guildId: string; channels: Record}): boolean { let changed = false; for (const [channelId, lastMessageId] of Object.entries(channels)) { const channel = this.channelsById.get(channelId); - if (!channel || channel.guildId !== guildId || !TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { + if ( + !channel || + channel.guildId !== guildId || + !(TEXT_BASED_CHANNEL_TYPES.has(channel.type) || THREAD_CHANNEL_TYPES.has(channel.type)) + ) { continue; } if (channel.lastMessageId != null && SnowflakeUtils.compare(lastMessageId, channel.lastMessageId) <= 0) { @@ -393,6 +436,10 @@ class Channels { if (!channel) { return; } + if (THREAD_CHANNEL_TYPES.has(channel.type)) { + this.bumpThreadCounters(channel, message); + return; + } if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { return; } @@ -407,6 +454,24 @@ class Channels { ); } + private bumpThreadCounters(channel: Channel, message: WireMessage): void { + if (channel.lastMessageId != null && SnowflakeUtils.compare(message.id, channel.lastMessageId) <= 0) { + return; + } + const skipCounters = + message.id === channel.id || + message.type === MessageTypes.THREAD_STARTER_MESSAGE || + (message.state != null && message.state !== MessageStates.SENT); + this.setChannel( + channel.withUpdates({ + last_message_id: message.id, + ...(skipCounters + ? {} + : {message_count: channel.messageCount + 1, total_message_sent: channel.totalMessageSent + 1}), + }), + ); + } + handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void { const guildChannels = this.getGuildChannels(guildId); if (guildChannels.length === 0) return; diff --git a/fluxer_app/src/features/channel/utils/ChannelCreateModalUtils.ts b/fluxer_app/src/features/channel/utils/ChannelCreateModalUtils.ts index e50b72c3b..b2af0179e 100644 --- a/fluxer_app/src/features/channel/utils/ChannelCreateModalUtils.ts +++ b/fluxer_app/src/features/channel/utils/ChannelCreateModalUtils.ts @@ -2,13 +2,17 @@ import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands'; import type {Channel} from '@app/features/channel/models/Channel'; +import {getForumChannelTypeOptions} from '@app/features/forum/utils/ForumChannelTypeOptions'; import {selectChannel} from '@app/features/navigation/commands/NavigationCommands'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; import { VOICE_CHANNEL_BITRATE_DEFAULT, VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT, } from '@fluxer/constants/src/LimitConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; +import type {I18n} from '@lingui/core'; export interface FormInputs { name: string; @@ -30,7 +34,7 @@ export interface ChannelTypeOption { desc: string; } -export const channelTypeOptions: Array = [ +const CHANNEL_TYPE_OPTIONS: ReadonlyArray = [ { value: ChannelTypes.GUILD_TEXT, name: 'Text Channel', @@ -53,8 +57,14 @@ export const channelTypeOptions: Array = [ }, ]; +export function getChannelTypeOptions(i18n: I18n, {forums = false}: {forums?: boolean} = {}): Array { + if (!forums) return [...CHANNEL_TYPE_OPTIONS]; + return [...CHANNEL_TYPE_OPTIONS, ...getForumChannelTypeOptions(i18n)]; +} + export async function createChannel(guildId: string, data: FormInputs, parentId?: string): Promise { const channelType = Number(data.type); + if (THREAD_ONLY_CHANNEL_TYPES.has(channelType) && !ThreadGuilds.isActive(guildId)) return; const channel = await ChannelCommands.create(guildId, { name: data.name, url: data.url, @@ -64,7 +74,7 @@ export async function createChannel(guildId: string, data: FormInputs, parentId? user_limit: channelType === ChannelTypes.GUILD_VOICE ? 0 : null, voice_connection_limit: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null, }); - if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { + if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) || THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) { setTimeout(() => { selectChannel(guildId, channel.id); }, 50); @@ -79,6 +89,7 @@ export async function duplicateChannel( options: DuplicateChannelOptions = {}, ): Promise { const {closeModal = true} = options; + if (THREAD_ONLY_CHANNEL_TYPES.has(sourceChannel.type) && !ThreadGuilds.isActive(guildId)) return; const channel = await ChannelCommands.create(guildId, { name: data.name, url: sourceChannel.type === ChannelTypes.GUILD_LINK ? sourceChannel.url : null, diff --git a/fluxer_app/src/features/channel/utils/ChannelSettingsModalUtils.ts b/fluxer_app/src/features/channel/utils/ChannelSettingsModalUtils.ts index 7ed9a0ce6..97aeea8fa 100644 --- a/fluxer_app/src/features/channel/utils/ChannelSettingsModalUtils.ts +++ b/fluxer_app/src/features/channel/utils/ChannelSettingsModalUtils.ts @@ -25,6 +25,9 @@ export function getAvailableTabs(i18n: I18n, channelId: string): Array tab.type !== 'webhooks'); } + if (channel.isThreadOnly()) { + filteredTabs = filteredTabs.filter((tab) => tab.type !== 'invites'); + } const permissionContext = {channelId: channel.id, guildId: channel.guildId}; const canUpdateRtcRegion = channel.type === ChannelTypes.GUILD_VOICE && Permission.can(Permissions.UPDATE_RTC_REGION, permissionContext); diff --git a/fluxer_app/src/features/channel/utils/ChannelUtils.tsx b/fluxer_app/src/features/channel/utils/ChannelUtils.tsx index 15da1b7c7..8efb060d4 100644 --- a/fluxer_app/src/features/channel/utils/ChannelUtils.tsx +++ b/fluxer_app/src/features/channel/utils/ChannelUtils.tsx @@ -4,6 +4,7 @@ import i18n from '@app/app/I18n'; import type {Channel} from '@app/features/channel/models/Channel'; import ChannelDisplayName from '@app/features/channel/state/ChannelDisplayName'; import {UNKNOWN_CHANNEL_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors'; +import {FORUM_DESCRIPTOR, MEDIA_DESCRIPTOR} from '@app/features/forum/utils/ForumMessageDescriptors'; 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'; @@ -25,7 +26,14 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {msg} from '@lingui/core/macro'; -import {CaretDownIcon, type IconProps, NotePencilIcon} from '@phosphor-icons/react'; +import { + CaretDownIcon, + ChatsIcon, + ChatsTeardropIcon, + type IconProps, + ImagesSquareIcon, + NotePencilIcon, +} from '@phosphor-icons/react'; const VOICE_MATURE_DESCRIPTOR = msg({ message: 'Voice (mature)', @@ -65,6 +73,10 @@ const TEXT_DESCRIPTOR = msg({ message: 'Text', comment: 'Channel type chip label for a text channel.', }); +const THREAD_DESCRIPTOR = msg({ + message: 'Thread', + comment: 'Channel type chip label for a thread inside a text channel.', +}); const UNKNOWN_USER_DESCRIPTOR = msg({ message: 'Unknown user', comment: 'Fallback name in channel display utilities when a user record is not available.', @@ -165,6 +177,14 @@ export function getIcon( return ; case ChannelTypes.DM_PERSONAL_NOTES: return ; + case ChannelTypes.ANNOUNCEMENT_THREAD: + case ChannelTypes.PUBLIC_THREAD: + case ChannelTypes.PRIVATE_THREAD: + return ; + case ChannelTypes.GUILD_FORUM: + return ; + case ChannelTypes.GUILD_MEDIA: + return ; default: return ; } @@ -180,6 +200,14 @@ export function getName(channel: Channel) { return channel.nsfw ? i18n._(LINK_MATURE_DESCRIPTOR) : i18n._(LINK_DESCRIPTOR); case ChannelTypes.GUILD_ANNOUNCEMENT: return channel.nsfw ? i18n._(ANNOUNCEMENT_MATURE_DESCRIPTOR) : i18n._(ANNOUNCEMENT_DESCRIPTOR); + case ChannelTypes.ANNOUNCEMENT_THREAD: + case ChannelTypes.PUBLIC_THREAD: + case ChannelTypes.PRIVATE_THREAD: + return i18n._(THREAD_DESCRIPTOR); + case ChannelTypes.GUILD_FORUM: + return i18n._(FORUM_DESCRIPTOR); + case ChannelTypes.GUILD_MEDIA: + return i18n._(MEDIA_DESCRIPTOR); default: return channel.nsfw ? i18n._(TEXT_MATURE_DESCRIPTOR) : i18n._(TEXT_DESCRIPTOR); } diff --git a/fluxer_app/src/features/experiment/state/ExperimentAssignments.test.ts b/fluxer_app/src/features/experiment/state/ExperimentAssignments.test.ts index ef65f744d..847baa46b 100644 --- a/fluxer_app/src/features/experiment/state/ExperimentAssignments.test.ts +++ b/fluxer_app/src/features/experiment/state/ExperimentAssignments.test.ts @@ -6,6 +6,7 @@ import type {DomainMigrationAssignmentResponse} from '@fluxer/schema/src/domains import { type ExperimentAssignmentsResponse, INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE, + readChannelThreadsAssignment, readDomainMigrationAssignment, } from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -132,6 +133,21 @@ describe('ExperimentAssignments response handling', () => { expect(readDomainMigrationAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT); }); + it('parses the channel_threads assignment alongside the others', async () => { + const envelope: ExperimentAssignmentsResponse = { + ...CANARY_ENVELOPE, + assignments: {...CANARY_ENVELOPE.assignments, channel_threads: {active: true, config_version: 3}}, + }; + await adopt(envelope); + expect(readChannelThreadsAssignment(ExperimentAssignments.response)).toEqual({active: true, config_version: 3}); + expect(readDomainMigrationAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT); + }); + + it('reads no channel_threads assignment when the key is absent', async () => { + await adopt(CANARY_ENVELOPE); + expect(readChannelThreadsAssignment(ExperimentAssignments.response)).toBeNull(); + }); + it('requests the shared experiment endpoint', async () => { await adopt(CANARY_ENVELOPE); expect(vi.mocked(http.get).mock.calls[0]?.[0]).toBe('/experiments'); diff --git a/fluxer_app/src/features/forum/commands/ForumCommands.ts b/fluxer_app/src/features/forum/commands/ForumCommands.ts new file mode 100644 index 000000000..e04a44b14 --- /dev/null +++ b/fluxer_app/src/features/forum/commands/ForumCommands.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Endpoints} from '@app/features/app/constants/Endpoints'; +import type {Channel, ChannelWire} from '@app/features/channel/models/Channel'; +import Channels from '@app/features/channel/state/Channels'; +import ForumPosts from '@app/features/forum/state/ForumPosts'; +import MessageQueue from '@app/features/messaging/state/MessageQueue'; +import {CloudUpload} from '@app/features/messaging/upload/CloudUpload'; +import {normalizeMessageContent} from '@app/features/messaging/utils/MessageRequestUtils'; +import {http} from '@app/features/platform/transport/RestTransport'; +import ChannelThreads from '@app/features/threads/state/ChannelThreads'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; +import {ChannelFlags} from '@fluxer/constants/src/ThreadConstants'; +import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; + +export interface ForumTagInput { + id?: string; + name: string; + moderated: boolean; + emoji_id: string | null; + emoji_name: string | null; +} + +export interface ForumChannelPatch { + topic?: string | null; + rate_limit_per_user?: number; + default_auto_archive_duration?: number | null; + default_thread_rate_limit_per_user?: number; + default_reaction_emoji?: {emoji_id: string | null; emoji_name: string | null} | null; + default_sort_order?: number | null; + default_forum_layout?: number; + default_tag_setting?: string | null; + available_tags?: ReadonlyArray; + flags?: number; +} + +export interface ForumPostInput { + name: string; + appliedTags: ReadonlyArray; + content: string; + nonce: string; + hasAttachments: boolean; + stickerIds: ReadonlyArray; +} + +function assertActive(channel: Channel): asserts channel is Channel & {guildId: string} { + if (!channel.guildId || !ThreadGuilds.isActive(channel.guildId)) { + throw new Error('Forums are not available in this community'); + } +} + +function ingestChannel(wire: ChannelWire | undefined): void { + if (!wire) return; + const existing = Channels.getChannel(wire.id); + Channels.handleChannelCreate({channel: existing ? existing.withUpdates(wire).toJSON() : wire}); +} + +export async function createForumPost(forum: Channel, input: ForumPostInput): Promise { + assertActive(forum); + const prepared = input.hasAttachments + ? await MessageQueue.prepareAttachmentsForSend({channelId: forum.id, nonce: input.nonce}) + : {}; + if (prepared == null) return undefined; + assertActive(forum); + const normalized = normalizeMessageContent(input.content); + const message: Record = {content: normalized.content}; + if (normalized.flags !== 0) message.flags = normalized.flags; + if (prepared.attachments?.length) message.attachments = prepared.attachments; + if (input.stickerIds.length > 0) message.sticker_ids = [...input.stickerIds]; + const payload = { + name: input.name, + auto_archive_duration: forum.defaultAutoArchiveDuration ?? undefined, + applied_tags: input.appliedTags.length > 0 ? [...input.appliedTags] : undefined, + message, + }; + let body: FormData | typeof payload = payload; + if (prepared.files?.length) { + const formData = new FormData(); + formData.append('payload_json', JSON.stringify(payload)); + prepared.files.forEach((file, index) => { + formData.append(`files[${index}]`, file); + }); + body = formData; + } + try { + const response = await http.post(Endpoints.CHANNEL_THREADS(forum.id), { + body, + onProgress: prepared.files?.length + ? (event) => { + if (event.lengthComputable && event.total > 0) { + CloudUpload.updateSendingProgress(input.nonce, (event.loaded / event.total) * 100); + } + } + : undefined, + }); + const {message: firstMessage, ...wire} = response.body; + if (input.hasAttachments) CloudUpload.removeMessageUpload(input.nonce); + if (firstMessage) ForumPosts.setFirstMessage(firstMessage); + return ChannelThreads.upsert(wire, forum.guildId); + } catch (error) { + if (input.hasAttachments) CloudUpload.restoreAttachmentsToTextarea(input.nonce); + throw error; + } +} + +export async function updateForumChannel(forum: Channel, patch: ForumChannelPatch): Promise { + assertActive(forum); + const response = await http.patch(Endpoints.CHANNEL(forum.id), {body: patch}); + ingestChannel(response.body); +} + +export async function createForumTag(forum: Channel, tag: ForumTagInput): Promise { + assertActive(forum); + const {id: _id, ...body} = tag; + const response = await http.post(Endpoints.CHANNEL_TAGS(forum.id), {body}); + ingestChannel(response.body); +} + +export async function updateForumTag(forum: Channel, tagId: string, tag: ForumTagInput): Promise { + assertActive(forum); + const {id: _id, ...body} = tag; + const response = await http.put(Endpoints.CHANNEL_TAG(forum.id, tagId), {body}); + ingestChannel(response.body); +} + +export async function deleteForumTag(forum: Channel, tagId: string): Promise { + assertActive(forum); + const response = await http.delete(Endpoints.CHANNEL_TAG(forum.id, tagId)); + ingestChannel(response.body); +} + +async function patchPost(post: Channel, body: Record): Promise { + assertActive(post); + const response = await http.patch(Endpoints.CHANNEL(post.id), {body}); + if (response.body) ChannelThreads.upsert(response.body, post.guildId); +} + +export function setPostPinned(post: Channel, pinned: boolean): Promise { + const flags = pinned ? post.flags | ChannelFlags.PINNED : post.flags & ~ChannelFlags.PINNED; + return patchPost(post, {flags}); +} + +export function setPostTags(post: Channel, appliedTags: ReadonlyArray): Promise { + return patchPost(post, {applied_tags: [...appliedTags]}); +} diff --git a/fluxer_app/src/features/forum/components/Forum.module.css b/fluxer_app/src/features/forum/components/Forum.module.css new file mode 100644 index 000000000..90f83d3af --- /dev/null +++ b/fluxer_app/src/features/forum/components/Forum.module.css @@ -0,0 +1,828 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.view { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-width: 0; + min-height: 0; + background-color: var(--background-primary); +} + +.scroller { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.content { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 60rem; + margin: 0 auto; + padding: 1rem; +} + +.guidelines { + border-radius: 0.5rem; + background-color: var(--background-secondary); +} + +.guidelinesToggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 0.75rem; + border: 0; + background: transparent; + color: var(--text-secondary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.guidelinesTitle { + flex: 1; + font-weight: 600; + color: var(--text-primary); +} + +.guidelinesBody { + padding: 0 0.75rem 0.75rem; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.entryCard { + display: flex; + flex-direction: column; + border: 0.0625rem solid var(--background-modifier-accent); + border-radius: 0.5rem; + background-color: var(--form-surface-background); + transition: border-color 150ms ease; +} + +.entryCard:focus-within { + border-color: var(--background-modifier-accent-focus); +} + +.entryRow { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 3rem; + padding: 0.375rem 0.375rem 0.375rem 0.75rem; +} + +.entryIcon { + flex-shrink: 0; + color: var(--text-tertiary); +} + +.entryInput { + flex: 1; + min-width: 0; + height: 2.125rem; + padding: 0; + border: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 0.9375rem; + line-height: 1.25rem; + outline: none; +} + +.entryInput::placeholder { + color: var(--text-tertiary); +} + +.entrySpacer { + flex: 1; +} + +.entryFooter { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + padding: 0.5rem 0.75rem; + border-top: 0.0625rem solid var(--background-modifier-accent); + font-size: 0.8125rem; + line-height: 1rem; + color: var(--text-primary-muted); +} + +.entryStatus { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.entryLink { + padding: 0; + border: 0; + background: transparent; + color: var(--text-link); + font: inherit; + font-weight: 500; + cursor: pointer; +} + +.entryLink:hover { + text-decoration: underline; +} + +.entryHint { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.filterRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.filterRow .tagRow { + display: contents; +} + +.filterPill { + border-radius: 1rem; +} + +.filterDivider { + align-self: stretch; + width: 0.0625rem; + margin: 0.25rem 0.125rem; + background-color: var(--background-modifier-accent); +} + +.composerCard { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 0.75rem 0.5rem 1rem; + border: 0.0625rem solid var(--background-modifier-accent-focus); + border-radius: 0.5rem; + background-color: var(--form-surface-background); +} + +.composerHead { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.composerTitle { + flex: 1; + min-width: 0; + padding: 0.25rem 0; + border: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 1.25rem; + font-weight: 600; + line-height: 1.75rem; + outline: none; +} + +.composerTitle::placeholder { + color: var(--text-tertiary); +} + +.composerBody { + min-height: 3rem; +} + +.composerBodyHidden { + display: none; +} + +.composerPreview { + display: flex; + flex-direction: column; + gap: 0.375rem; + min-height: 3rem; + padding: 0.5rem 0.75rem; + border: 0.0625rem dashed var(--background-modifier-accent); + border-radius: 0.375rem; + overflow-wrap: anywhere; +} + +.composerPreviewLabel { + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; + color: var(--text-primary-muted); +} + +.composerError { + font-size: 0.8125rem; + color: var(--status-danger); +} + +.composerBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: 0 -0.25rem 0 -0.5rem; + padding-top: 0.5rem; + border-top: 0.0625rem solid var(--background-modifier-accent); +} + +.composerBarStart, +.composerBarEnd { + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 0; +} + +.composerBarStart { + flex-wrap: wrap; +} + +.composerIconButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2rem; + height: 2rem; + padding: 0; + border: 0; + border-radius: 0.375rem; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; +} + +.composerIconButton:hover { + color: var(--text-primary); + background-color: var(--background-modifier-hover); +} + +.composerIconButtonActive { + color: var(--text-primary); + background-color: var(--background-modifier-selected); +} + +.composerTags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + margin-left: 0.25rem; + min-width: 0; +} + +.tagPopout { + max-width: 22rem; + padding: 0.75rem; + border: 0.0625rem solid var(--background-modifier-accent); + border-radius: 0.5rem; + background-color: var(--background-secondary); +} + +.skeletonCard { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 1rem; + border: 0.0625rem solid transparent; + border-radius: 0.5rem; + background-color: var(--background-secondary); +} + +.clearButton { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 0.375rem; + background-color: transparent; + color: var(--text-tertiary); + cursor: pointer; +} + +.clearButton:hover { + color: var(--text-primary); + background-color: var(--background-modifier-hover); +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2rem; + height: 2rem; + padding: 0; + border: 0; + border-radius: 0.375rem; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; +} + +.iconButton:hover { + color: var(--text-primary); + background-color: var(--background-modifier-hover); +} + +.tagRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + min-width: 0; +} + +.tagClear { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border: 0.0625rem solid transparent; + border-radius: 1rem; + background: transparent; + color: var(--text-primary-muted); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25rem; + cursor: pointer; +} + +.tagClear:hover { + color: var(--text-primary); + background-color: var(--background-modifier-hover); +} + +.tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 12rem; + padding: 0.125rem 0.5rem; + border: 0.0625rem solid var(--background-modifier-accent); + border-radius: 1rem; + background-color: var(--background-secondary-alt); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25rem; +} + +.tagButton { + font-family: inherit; + cursor: pointer; +} + +.tagButton:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--background-modifier-hover); +} + +.tagButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tagSelected { + border-color: var(--brand-primary); + background-color: var(--brand-primary); + color: var(--text-on-brand-primary); +} + +.tagSelected:hover:not(:disabled) { + background-color: var(--brand-primary); + color: var(--text-on-brand-primary); +} + +.tagName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emoji { + width: 1rem; + height: 1rem; + object-fit: contain; +} + +.tagPicker { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.hint { + font-size: 0.75rem; + color: var(--text-primary-muted); +} + +.sectionTitle { + padding-top: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-tertiary); +} + +.postList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.postGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.75rem; +} + +.card { + display: flex; + gap: 0.75rem; + min-width: 0; + padding: 0.75rem; + border: 0.0625rem solid transparent; + border-radius: 0.5rem; + background-color: var(--background-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.card:hover { + background-color: var(--background-modifier-hover); +} + +.cardOpen, +.cardOpen:hover { + border-color: var(--background-modifier-accent-focus); + background-color: var(--background-modifier-selected); +} + +.cardDot { + flex-shrink: 0; + margin-right: 0.25rem; + width: 0.25rem; + height: 0.25rem; + border-radius: 50%; + background-color: var(--text-tertiary); +} + +.cardGrid { + flex-direction: column; + gap: 0; + padding: 0; + overflow: hidden; +} + +.cardGrid .cardBody { + padding: 0.75rem; +} + +.cardBody { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.375rem; + min-width: 0; +} + +.cardHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + color: var(--text-tertiary); +} + +.cardHeader:empty { + display: none; +} + +.cardTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 1rem; + font-weight: 600; + color: var(--text-secondary); +} + +.cardUnread .cardTitle { + color: var(--text-primary); +} + +.cardPreview { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: 0.875rem; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.cardAuthor { + font-weight: 600; + color: var(--text-primary); +} + +.cardFooter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; +} + +.cardMeta { + display: inline-flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; + color: var(--text-primary-muted); +} + +.cardUnreadLabel { + font-weight: 600; + color: var(--brand-primary-light); +} + +:global(.theme-light) .cardUnreadLabel { + color: var(--brand-primary); +} + +.newBadge { + padding: 0 0.375rem; + border-radius: 0.25rem; + background-color: var(--status-danger); + color: var(--text-on-brand-primary); + font-size: 0.6875rem; + font-weight: 700; + line-height: 1.125rem; + text-transform: uppercase; +} + +.cardThumb { + position: relative; + flex-shrink: 0; + width: 6rem; + height: 6rem; + overflow: hidden; + border-radius: 0.375rem; + background-color: var(--background-tertiary); +} + +.cardMedia { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + background-color: var(--background-tertiary); +} + +.cardMediaImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cardMediaPlaceholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--text-tertiary); +} + +.cardMediaPlay { + position: absolute; + top: 50%; + left: 50%; + display: flex; + padding: 0.5rem; + border-radius: 50%; + background-color: rgb(0 0 0 / 0.6); + color: var(--text-on-brand-primary); + transform: translate(-50%, -50%); +} + +.reaction { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.375rem; + border: 0.0625rem solid transparent; + border-radius: 0.5rem; + background-color: var(--background-tertiary); + color: var(--text-primary-muted); + font: inherit; + font-size: 0.875rem; + font-weight: 600; + line-height: 1rem; + cursor: pointer; + transition: + background-color 0.1s ease, + border-color 0.1s ease; +} + +.reaction:hover:not(:disabled) { + background-color: var(--background-modifier-hover); + border-color: var(--background-modifier-accent); +} + +.reaction:disabled { + cursor: default; +} + +.reaction .emoji { + width: 1.25rem; + height: 1.25rem; + margin: 0.125rem 0; +} + +.reactionActive { + background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%); + border-color: var(--brand-primary); + color: var(--text-on-brand-primary); +} + +.reactionActive:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%); + border-color: var(--brand-primary); +} + +:global(.theme-light) .reaction { + background-color: color-mix(in srgb, var(--brand-primary-light) 6%, var(--background-primary) 94%); + border-color: color-mix(in srgb, var(--brand-primary-light) 10%, var(--background-secondary) 90%); + color: var(--text-primary); +} + +:global(.theme-light) .reactionActive { + background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%); + border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%); + color: var(--brand-primary); +} + +.notice { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + color: var(--text-secondary); + text-align: center; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 3rem 1rem; + color: var(--text-primary-muted); + text-align: center; +} + +.emptyIcon { + color: var(--text-tertiary); +} + +.emptyTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.settingsSection { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.fieldLabel { + margin-bottom: -0.375rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + color: var(--text-primary); +} + +.settingsRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.tagList { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.tagListItem { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.5rem; + padding: 0.25rem 0.25rem 0.25rem 0.75rem; + border: 0.0625rem solid var(--background-modifier-accent); + border-radius: 0.5rem; + background-color: var(--form-surface-background); + color: var(--text-primary-muted); +} + +.tagListEmoji { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; +} + +.tagListName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + color: var(--text-primary); +} + +.emojiPickerButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border: 0.0625rem solid var(--background-modifier-accent); + border-radius: 0.5rem; + background-color: var(--background-tertiary); + color: var(--text-tertiary); + cursor: pointer; +} + +.emojiPickerButton .emoji { + width: 1.5rem; + height: 1.5rem; +} + +.sortPanel { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 14rem; + padding: 0.75rem 1rem; + border: 0.0625rem solid var(--background-modifier-accent); + border-radius: 0.5rem; + background-color: var(--background-secondary); +} + +.sortSection { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sortHeading { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; + color: var(--text-primary-muted); +} + +.sortPanelSheet { + min-width: 0; + padding: 1rem 0; + border: 0; + border-radius: 0; + background-color: transparent; +} diff --git a/fluxer_app/src/features/forum/components/ForumChannelView.tsx b/fluxer_app/src/features/forum/components/ForumChannelView.tsx new file mode 100644 index 000000000..7bc4c0405 --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumChannelView.tsx @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine'; +import {ChannelHeader} from '@app/features/channel/components/ChannelHeader'; +import type {Channel} from '@app/features/channel/models/Channel'; +import styles from '@app/features/forum/components/Forum.module.css'; +import {ForumPostCard} from '@app/features/forum/components/ForumPostCard'; +import {ForumPostComposer} from '@app/features/forum/components/ForumPostComposer'; +import {ForumFilterRow, ForumSearchCard} from '@app/features/forum/components/ForumToolbar'; +import {getForumPostListView, useForumViewing, usePostDataPrefetch} from '@app/features/forum/hooks/useForumPosts'; +import ForumPosts from '@app/features/forum/state/ForumPosts'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {SafeMarkdown} from '@app/features/messaging/components/markdown'; +import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes'; +import {Button} from '@app/features/ui/button/Button'; +import {Spinner} from '@app/features/ui/components/Spinner'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import {ForumLayoutTypes, THREAD_SEARCH_MAX_LIMIT} from '@fluxer/constants/src/ThreadConstants'; +import {useLingui} from '@lingui/react/macro'; +import {CaretDownIcon, CaretUpIcon, ChatsTeardropIcon, InfoIcon} from '@phosphor-icons/react'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; +import {useCallback, useEffect, useState} from 'react'; + +const ForumGuidelines = observer(({forum}: {forum: Channel}) => { + const {i18n} = useLingui(); + const [expanded, setExpanded] = useState(false); + if (!forum.topic) return null; + return ( +
+ + + + {expanded && ( +
+ +
+ )} +
+ ); +}); + +const ForumEmptyState = ({searching}: {searching: boolean}) => { + const {i18n} = useLingui(); + return ( +
+ + + {i18n._(searching ? D.NO_MATCHING_POSTS_DESCRIPTOR : D.NO_POSTS_DESCRIPTOR)} + + + {i18n._(searching ? D.NO_MATCHING_POSTS_HINT_DESCRIPTOR : D.NO_POSTS_HINT_DESCRIPTOR)} + +
+ ); +}; + +const SKELETON_CARD_COUNT = 3; + +const ForumPostSkeletons = ({grid}: {grid: boolean}) => ( +
+ {Array.from({length: SKELETON_CARD_COUNT}, (_, index) => ( +
+ + + +
+ ))} +
+); + +const PostCards = observer(({forum, posts, grid}: {forum: Channel; posts: ReadonlyArray; grid: boolean}) => ( +
+ {posts.map((post) => ( + + ))} +
+)); + +const ForumPostList = observer(({forum}: {forum: Channel}) => { + const {i18n} = useLingui(); + const view = getForumPostListView(forum); + const grid = ForumPosts.getLayout(forum) === ForumLayoutTypes.GRID; + const current = view.pinned ? [view.pinned, ...view.posts] : view.posts; + usePostDataPrefetch(forum, current.length > 0 ? [...current, ...view.archived] : view.archived); + const kind = view.searching ? 'search' : 'archived'; + const {list} = view; + const handleLoadMore = useCallback(() => ForumPosts.loadMore(forum, kind), [forum, kind]); + const handleRetry = useCallback(() => ForumPosts.retry(forum, kind), [forum, kind]); + const settled = !list.loading && !list.indexing; + const needsFirstPage = !view.searching && !list.loaded && settled && !list.failed; + useEffect(() => { + if (needsFirstPage && current.length < THREAD_SEARCH_MAX_LIMIT) ForumPosts.loadMore(forum, 'archived'); + }, [forum, needsFirstPage, current.length]); + const empty = current.length === 0 && view.archived.length === 0; + const searchPending = view.searching && (!list.loaded || list.loading) && !list.indexing && !list.failed; + if (searchPending && current.length === 0) { + return ; + } + return ( + <> + {current.length > 0 && ( + + )} + {view.archived.length > 0 && ( + <> +
+ {i18n._(D.OLDER_POSTS_DESCRIPTOR)} +
+ + + )} + {empty && settled && list.loaded && ( + + )} + {list.indexing && ( +
+ + {i18n._(D.SEARCH_INDEXING_DESCRIPTOR)} +
+ )} + {list.loading && ( +
+ +
+ )} + {list.failed && ( +
+ {i18n._(D.SEARCH_FAILED_DESCRIPTOR)} + +
+ )} + {settled && + !list.failed && + (list.loaded ? list.hasMore : needsFirstPage && current.length >= THREAD_SEARCH_MAX_LIMIT) && ( +
+ +
+ )} + + ); +}); + +export const ForumChannelView = observer(({forum}: {forum: Channel}) => { + const [composerTitle, setComposerTitle] = useState(null); + useForumViewing(forum); + const handleNewPost = useCallback((title: string) => setComposerTitle(title.trim()), []); + const handleCloseComposer = useCallback(() => setComposerTitle(null), []); + return ( +
+ +
+
+ + {composerTitle == null ? ( + <> + + + + ) : ( + + )} + +
+
+
+ ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumPostActions.module.css b/fluxer_app/src/features/forum/components/ForumPostActions.module.css new file mode 100644 index 000000000..f8adc1b1d --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumPostActions.module.css @@ -0,0 +1,13 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.actions { + grid-row: 4; + grid-column: -2 / -1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0 0.75rem; + margin-right: 1rem; + border-bottom: 0.0625rem solid var(--background-modifier-accent); +} diff --git a/fluxer_app/src/features/forum/components/ForumPostActions.tsx b/fluxer_app/src/features/forum/components/ForumPostActions.tsx new file mode 100644 index 000000000..74f32cd14 --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumPostActions.tsx @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import {EmojiPickerPopout} from '@app/features/emoji/components/popouts/EmojiPickerPopout'; +import type {UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes'; +import styles from '@app/features/forum/components/ForumPostActions.module.css'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands'; +import {buildChannelLink} from '@app/features/messaging/utils/MessageLinkUtils'; +import {toReactionEmoji} from '@app/features/messaging/utils/ReactionUtils'; +import Permission from '@app/features/permissions/state/Permission'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import * as ThreadCommands from '@app/features/threads/commands/ThreadCommands'; +import {reportThreadActionError} from '@app/features/threads/hooks/useThreadMenuData'; +import ThreadMemberships from '@app/features/threads/state/ThreadMemberships'; +import {canJoinThreadChannel} from '@app/features/threads/utils/ThreadActionRules'; +import {Button} from '@app/features/ui/button/Button'; +import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; +import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; +import {Popout} from '@app/features/ui/popover/PopoverPopout'; +import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {useLingui} from '@lingui/react/macro'; +import {BellIcon, CheckIcon, LinkSimpleIcon, SmileyIcon} from '@phosphor-icons/react'; +import {observer} from 'mobx-react-lite'; +import {useState} from 'react'; + +export const ForumPostActions = observer(({post, messageId}: {post: Channel; messageId: string}) => { + const {i18n} = useLingui(); + const [busy, setBusy] = useState(false); + const following = ThreadMemberships.isMember(post.id); + const canReact = Permission.can(Permissions.ADD_REACTIONS, post) && !post.isLocked && !post.isArchived; + const canFollow = following || canJoinThreadChannel(post); + const toggleFollow = () => { + setBusy(true); + void (following ? ThreadCommands.leaveThread(post) : ThreadCommands.joinThread(post)) + .catch((error) => reportThreadActionError(i18n, error)) + .finally(() => setBusy(false)); + }; + const copyLink = () => { + void TextCopyCommands.copy(i18n, buildChannelLink({guildId: post.guildId, channelId: post.id}), true); + ToastCommands.createToast({type: 'success', children: i18n._(D.POST_LINK_COPIED_DESCRIPTOR)}); + }; + return ( +
+ {canReact && ( + ( + { + ReactionCommands.addReaction(i18n, post.id, messageId, toReactionEmoji(emoji as UnicodeEmoji)); + onClose(); + }} + onClose={onClose} + data-flx="forum.forum-post-actions.emoji-picker-popout" + /> + )} + data-flx="forum.forum-post-actions.react-popout" + > + + + )} + {canFollow && ( + + )} + +
+ ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumPostCard.tsx b/fluxer_app/src/features/forum/components/ForumPostCard.tsx new file mode 100644 index 000000000..1ab355b1d --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumPostCard.tsx @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import Emoji from '@app/features/emoji/state/Emoji'; +import styles from '@app/features/forum/components/Forum.module.css'; +import {ForumPostMenuSheet, openForumPostContextMenu} from '@app/features/forum/components/ForumPostContextMenu'; +import {ForumEmoji, ForumTagPill} from '@app/features/forum/components/ForumTagPill'; +import ForumPosts from '@app/features/forum/state/ForumPosts'; +import ForumReadState from '@app/features/forum/state/ForumReadState'; +import {getDefaultReaction, getForumTags} from '@app/features/forum/utils/ForumChannelUtils'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {isPinnedPost} from '@app/features/forum/utils/ForumPostList'; +import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands'; +import {Message} from '@app/features/messaging/models/MessagingMessage'; +import {buildFitInsideMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils'; +import Navigation from '@app/features/navigation/state/Navigation'; +import {buildMessageNotificationBody} from '@app/features/notification/utils/MessageNotificationPreview'; +import Permission from '@app/features/permissions/state/Permission'; +import ReadStates from '@app/features/read_state/state/ReadStates'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {openThread} from '@app/features/threads/commands/ThreadNavigation'; +import {lastActivityId} from '@app/features/threads/state/ChannelThreads'; +import ThreadMemberships from '@app/features/threads/state/ThreadMemberships'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import Users from '@app/features/user/state/Users'; +import * as DateUtils from '@app/features/user/utils/DateFormatting'; +import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; +import {MessageAttachmentFlags, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import type {DefaultReactionEmojiResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; +import type { + MessageAttachment, + Message as WireMessage, +} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; +import {useLingui} from '@lingui/react/macro'; +import {ChatCircleIcon, PlayIcon, PushPinSimpleIcon} from '@phosphor-icons/react'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; +import {useCallback, useMemo, useState} from 'react'; + +const MAX_CARD_TAGS = 3; +const LIST_THUMBNAIL_SIZE = 96; +const GRID_THUMBNAIL_SIZE = 480; + +function isVisualAttachment(attachment: MessageAttachment): boolean { + const type = attachment.content_type ?? ''; + return ( + (type.startsWith('image/') || type.startsWith('video/')) && + (attachment.flags & MessageAttachmentFlags.IS_SPOILER) === 0 && + !attachment.nsfw && + !attachment.expired + ); +} + +function firstMedia(message: WireMessage | null | undefined): {src: string; video: boolean} | null { + const attachment = message?.attachments?.find(isVisualAttachment); + const source = attachment?.proxy_url ?? attachment?.url; + if (!attachment || !source) return null; + return {src: source, video: (attachment.content_type ?? '').startsWith('video/')}; +} + +function defaultReactionState( + message: WireMessage | null | undefined, + reaction: DefaultReactionEmojiResponse, +): {count: number; me: boolean} { + const match = message?.reactions?.find((entry) => + reaction.emoji_id != null ? entry.emoji.id === reaction.emoji_id : entry.emoji.name === reaction.emoji_name, + ); + return {count: match?.count ?? 0, me: match?.me ?? false}; +} + +const DefaultReactionButton = observer( + ({forum, post, message}: {forum: Channel; post: Channel; message: WireMessage | null | undefined}) => { + const {i18n} = useLingui(); + const reaction = getDefaultReaction(forum); + if (!reaction || !message) return null; + const state = defaultReactionState(message, reaction); + const canReact = Permission.can(Permissions.ADD_REACTIONS, forum) || state.me || state.count > 0; + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (!canReact) return; + const emoji = { + id: reaction.emoji_id, + name: + reaction.emoji_name ?? (reaction.emoji_id ? (Emoji.getEmojiById(reaction.emoji_id)?.name ?? 'emoji') : ''), + }; + if (state.me) { + ReactionCommands.removeReaction(i18n, post.id, message.id, emoji); + } else { + ReactionCommands.addReaction(i18n, post.id, message.id, emoji); + } + }; + return ( + + + + ); + }, +); + +interface ForumPostCardProps { + forum: Channel; + post: Channel; + grid: boolean; +} + +export const ForumPostCard = observer(({forum, post, grid}: ForumPostCardProps) => { + const {i18n} = useLingui(); + const [sheetOpen, setSheetOpen] = useState(false); + const firstMessage = ForumPosts.getFirstMessage(post.id); + const preview = useMemo(() => { + if (firstMessage === undefined) return null; + if (firstMessage === null) return i18n._(D.ORIGINAL_MESSAGE_DELETED_DESCRIPTOR); + const record = new Message(firstMessage, {skipReactionHydration: true}); + return buildMessageNotificationBody(record, i18n); + }, [firstMessage, i18n]); + const media = firstMedia(firstMessage); + const authorId = firstMessage?.author.id ?? post.ownerId; + const author = authorId ? Users.getUser(authorId) : undefined; + const authorName = author ? NicknameUtils.getNickname(author, forum.guildId, forum.id) : null; + const tags = getForumTags(forum, post.appliedTags); + const joined = ThreadMemberships.isMember(post.id); + const postUnreadCount = joined ? 0 : ForumReadState.getPostUnreadCount(post.id); + const unread = (joined && ReadStates.hasUnread(post.id)) || postUnreadCount > 0; + const unreadCount = joined && unread ? ReadStates.getUnreadCount(post.id) : postUnreadCount; + const isNew = !unread && ForumReadState.isNewPost(post); + const pinned = isPinnedPost(post); + const open = Navigation.threadId === post.id; + const lastActivity = new Date(SnowflakeUtils.extractTimestamp(lastActivityId(post))); + const handleOpen = useCallback(() => openThread(post), [post]); + const handleContextMenu = useCallback( + (event: React.MouseEvent) => { + if (MobileLayout.enabled) { + event.preventDefault(); + setSheetOpen(true); + return; + } + openForumPostContextMenu(event, post); + }, + [post], + ); + const thumbnail = media + ? buildFitInsideMediaProxyURL(media.src, { + width: grid ? GRID_THUMBNAIL_SIZE : LIST_THUMBNAIL_SIZE, + height: grid ? GRID_THUMBNAIL_SIZE : LIST_THUMBNAIL_SIZE, + }) + : null; + return ( + <> + +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleOpen(); + } + }} + onContextMenu={handleContextMenu} + data-flx="forum.forum-post-card.card.open" + > + {grid && ( +
+ {thumbnail ? ( + + ) : ( +
+ +
+ )} + {media?.video && ( + + + + )} +
+ )} +
+
+ {pinned && ( + + )} + {isNew && ( + + {i18n._(D.NEW_BADGE_DESCRIPTOR)} + + )} + {tags.slice(0, MAX_CARD_TAGS).map((tag) => ( + + ))} + {tags.length > MAX_CARD_TAGS && ( + {`+${tags.length - MAX_CARD_TAGS}`} + )} +
+
+ {post.name} +
+ {preview != null && ( +
+ {authorName && ( + {`${authorName}: `} + )} + {preview} +
+ )} +
+ + + + {i18n._(D.POST_COUNT_DESCRIPTOR, {count: post.messageCount})} + + {unread && ( + + {unreadCount > 0 + ? i18n._(D.UNREAD_MESSAGES_DESCRIPTOR, {count: unreadCount}) + : i18n._(D.HAS_UNREAD_MESSAGES_DESCRIPTOR)} + + )} + + + {DateUtils.getRelativeDateString(lastActivity, i18n)} + +
+
+ {!grid && thumbnail && ( +
+ + {media?.video && ( + + + + )} +
+ )} +
+
+ {sheetOpen && ( + setSheetOpen(false)} + data-flx="forum.forum-post-card.forum-post-menu-sheet" + /> + )} + + ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumPostComposer.tsx b/fluxer_app/src/features/forum/components/ForumPostComposer.tsx new file mode 100644 index 000000000..21283e7f5 --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumPostComposer.tsx @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type ChannelTextareaControls, + LexicalChannelTextareaContent, +} from '@app/features/channel/components/LexicalChannelTextareaContent'; +import type {Channel} from '@app/features/channel/models/Channel'; +import {EmojiPickerPopout} from '@app/features/emoji/components/popouts/EmojiPickerPopout'; +import * as ForumCommands from '@app/features/forum/commands/ForumCommands'; +import styles from '@app/features/forum/components/Forum.module.css'; +import {ForumComposerTags} from '@app/features/forum/components/ForumTagPicker'; +import {isMediaChannel, isTagRequired} from '@app/features/forum/utils/ForumChannelUtils'; +import {reportForumError} from '@app/features/forum/utils/ForumErrors'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import * as DraftCommands from '@app/features/messaging/commands/DraftCommands'; +import {SafeMarkdown} from '@app/features/messaging/components/markdown'; +import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes'; +import {useTextareaAttachments} from '@app/features/messaging/hooks/useCloudUpload'; +import type {SendMessageFunction} from '@app/features/messaging/hooks/useMessageSubmission'; +import Drafts from '@app/features/messaging/state/MessagingDrafts'; +import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils'; +import {hasVisibleMessageContent} from '@app/features/messaging/utils/VisibleMessageContent'; +import Permission from '@app/features/permissions/state/Permission'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {openThread} from '@app/features/threads/commands/ThreadNavigation'; +import ThreadPanel from '@app/features/threads/state/ThreadPanel'; +import * as ThreadD from '@app/features/threads/utils/ThreadMessageDescriptors'; +import {Button} from '@app/features/ui/button/Button'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import {Popout} from '@app/features/ui/popover/PopoverPopout'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {THREAD_NAME_MAX_LENGTH} from '@fluxer/constants/src/ThreadConstants'; +import type {MessageStickerItem} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; +import {useLingui} from '@lingui/react/macro'; +import {EyeIcon, ImageSquareIcon, SmileyIcon, XIcon} from '@phosphor-icons/react'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +function useCooldownSeconds(until: number): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (until <= Date.now()) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [until]); + return Math.max(0, Math.ceil((until - now) / 1000)); +} + +function resolvePostArgs( + stickersOrTts?: Array | boolean, + favoriteMemeIdOrStickers?: string | Array, + maybeFavoriteMemeId?: string, +): {stickers: Array; favoriteMemeId?: string} { + if (typeof stickersOrTts === 'boolean') { + return { + stickers: Array.isArray(favoriteMemeIdOrStickers) ? favoriteMemeIdOrStickers : [], + favoriteMemeId: maybeFavoriteMemeId, + }; + } + return { + stickers: stickersOrTts ?? [], + favoriteMemeId: typeof favoriteMemeIdOrStickers === 'string' ? favoriteMemeIdOrStickers : undefined, + }; +} + +interface ForumPostComposerProps { + forum: Channel; + initialTitle: string; + onClose: () => void; +} + +export const ForumPostComposer = observer(({forum, initialTitle, onClose}: ForumPostComposerProps) => { + const {i18n} = useLingui(); + const [title, setTitle] = useState(initialTitle.slice(0, THREAD_NAME_MAX_LENGTH)); + const [tags, setTags] = useState>([]); + const [error, setError] = useState(undefined); + const [body, setBody] = useState(''); + const [preview, setPreview] = useState(false); + const controlsRef = useRef(null); + const titleRef = useRef(null); + useEffect(() => { + titleRef.current?.focus(); + }, []); + const stateRef = useRef({title, tags}); + stateRef.current = {title, tags}; + const draftKey = `forum-post:${forum.id}`; + const media = isMediaChannel(forum); + const attachments = useTextareaAttachments(draftKey); + const bypassesSlowmode = Permission.can(Permissions.BYPASS_SLOWMODE, forum); + const cooldownSeconds = useCooldownSeconds(bypassesSlowmode ? 0 : ThreadPanel.getCreateCooldownUntil(forum.id)); + const handleSubmit: SendMessageFunction = useCallback( + (content, hasAttachments, stickersOrTts, favoriteMemeIdOrStickers, maybeFavoriteMemeId) => { + const {stickers, favoriteMemeId} = resolvePostArgs(stickersOrTts, favoriteMemeIdOrStickers, maybeFavoriteMemeId); + if (favoriteMemeId) { + setError(i18n._(D.POST_FAVORITE_MEDIA_UNSUPPORTED_DESCRIPTOR)); + return false; + } + const name = stateRef.current.title.trim(); + if (!name) { + setError(i18n._(D.POST_TITLE_REQUIRED_DESCRIPTOR)); + return false; + } + if (isTagRequired(forum) && stateRef.current.tags.length === 0) { + setError(i18n._(D.POST_TAG_REQUIRED_DESCRIPTOR)); + return false; + } + if (media && !hasAttachments) { + setError(i18n._(D.POST_MEDIA_REQUIRED_DESCRIPTOR)); + return false; + } + const nonce = SnowflakeUtils.fromTimestamp(Date.now()); + const claimed = hasAttachments ? MessageSubmitUtils.claimMessageAttachments(draftKey, nonce, content) : []; + void ForumCommands.createForumPost(forum, { + name, + appliedTags: stateRef.current.tags, + content, + nonce, + hasAttachments: claimed.length > 0, + stickerIds: stickers.map((sticker) => sticker.id), + }) + .then((post) => { + if (!post) return; + ThreadPanel.startCreateCooldown(forum.id, forum.rateLimitPerUser); + onClose(); + openThread(post); + }) + .catch((failure) => { + if (content && !Drafts.getDraft(draftKey)) DraftCommands.createDraft(draftKey, content); + reportForumError(i18n, failure, D.POST_CREATE_FAILED_DESCRIPTOR); + }); + return true; + }, + [forum, i18n, media, onClose, draftKey], + ); + const ready = + title.trim().length > 0 && + cooldownSeconds === 0 && + (media ? attachments.length > 0 : hasVisibleMessageContent(body) || attachments.length > 0) && + (!isTagRequired(forum) || tags.length > 0); + const mobile = MobileLayout.enabled; + return ( +
+
+ { + setTitle(event.target.value); + setError(undefined); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + controlsRef.current?.focus(); + }} + placeholder={i18n._(D.POST_TITLE_PLACEHOLDER_DESCRIPTOR)} + aria-label={i18n._(D.POST_TITLE_DESCRIPTOR)} + maxLength={THREAD_NAME_MAX_LENGTH} + ref={titleRef} + autoComplete="off" + data-flx="forum.forum-post-composer.input.set-title" + /> + + + +
+
+ 0} + typingEnabled={false} + onSubmit={handleSubmit} + enableReply={false} + enableEditLast={false} + enableSlashCommands={false} + draftChannelId={draftKey} + placeholder={i18n._(media ? D.POST_MEDIA_PLACEHOLDER_DESCRIPTOR : D.POST_MESSAGE_PLACEHOLDER_DESCRIPTOR)} + bare + controlsRef={controlsRef} + onValueChange={setBody} + data-flx="forum.forum-post-composer.lexical-channel-textarea-content.submit" + /> +
+ {preview && ( +
+ + {i18n._(D.PREVIEW_DESCRIPTOR)} + + {hasVisibleMessageContent(body) ? ( + + ) : ( + + {i18n._(D.PREVIEW_EMPTY_DESCRIPTOR)} + + )} +
+ )} + {cooldownSeconds > 0 && ( +
+ {i18n._(ThreadD.SLOWMODE_COOLDOWN_DESCRIPTOR, {seconds: cooldownSeconds})} +
+ )} + {error && ( +
+ {error} +
+ )} +
+
+ + + + + + {!mobile && ( + ( + controlsRef.current?.insertEmoji(emoji)} + onClose={closePicker} + data-flx="forum.forum-post-composer.emoji-picker-popout" + /> + )} + data-flx="forum.forum-post-composer.emoji-popout" + > + + + + + + + )} + {forum.availableTags.length > 0 && ( + { + setTags(next); + setError(undefined); + }} + data-flx="forum.forum-post-composer.forum-tag-picker.set-tags" + /> + )} +
+
+ + + + + + +
+
+
+ ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumPostContextMenu.tsx b/fluxer_app/src/features/forum/components/ForumPostContextMenu.tsx new file mode 100644 index 000000000..a3312d79c --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumPostContextMenu.tsx @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import * as ForumCommands from '@app/features/forum/commands/ForumCommands'; +import {ForumPostTagsModal} from '@app/features/forum/components/ForumPostTagsModal'; +import {getPostForum} from '@app/features/forum/utils/ForumChannelUtils'; +import {reportForumError} from '@app/features/forum/utils/ForumErrors'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {canEditPostTags} from '@app/features/forum/utils/ForumPermissions'; +import {isPinnedPost} from '@app/features/forum/utils/ForumPostList'; +import {useThreadMenuData} from '@app/features/threads/hooks/useThreadMenuData'; +import {canPatchThread, isThreadModeratorFor} from '@app/features/threads/utils/ThreadActionRules'; +import {DataMenuRenderer} from '@app/features/ui/action_menu/DataMenuRenderer'; +import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; +import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; +import {modal} from '@app/features/ui/commands/ModalCommands'; +import {MenuBottomSheet, type MenuGroupType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; +import {ChannelFlags} from '@fluxer/constants/src/ThreadConstants'; +import {useLingui} from '@lingui/react/macro'; +import {PushPinSimpleIcon, PushPinSimpleSlashIcon, TagIcon} from '@phosphor-icons/react'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; + +export function openForumPostTagsModal(post: Channel): void { + ModalCommands.push( + modal(() => ( + + )), + ); +} + +function useForumPostMenuData(post: Channel, onClose: () => void): Array { + const {i18n} = useLingui(); + const {groups} = useThreadMenuData(post, {onClose}); + const forum = getPostForum(post); + if (!forum) return groups; + const pinned = isPinnedPost(post); + const items: MenuGroupType['items'] = []; + const moderator = isThreadModeratorFor(post); + const nextFlags = pinned ? post.flags & ~ChannelFlags.PINNED : post.flags | ChannelFlags.PINNED; + if (moderator && !post.isArchived && canPatchThread(post, {flags: nextFlags})) { + items.push({ + icon: pinned ? ( + + ) : ( + + ), + label: pinned ? i18n._(D.UNPIN_POST_DESCRIPTOR) : i18n._(D.PIN_POST_DESCRIPTOR), + onClick: () => { + void ForumCommands.setPostPinned(post, !pinned).catch((error) => reportForumError(i18n, error)); + onClose(); + }, + }); + } + if (forum.availableTags.length > 0 && canEditPostTags(post)) { + items.push({ + icon: , + label: i18n._(D.EDIT_TAGS_DESCRIPTOR), + onClick: () => { + openForumPostTagsModal(post); + onClose(); + }, + }); + } + return items.length > 0 ? [{items}, ...groups] : groups; +} + +const ForumPostContextMenu = observer(({post, onClose}: {post: Channel; onClose: () => void}) => { + const groups = useForumPostMenuData(post, onClose); + return ; +}); + +export const ForumPostMenuSheet = observer(({post, onClose}: {post: Channel; onClose: () => void}) => { + const groups = useForumPostMenuData(post, onClose); + return ( + + ); +}); + +export function openForumPostContextMenu(event: React.MouseEvent, post: Channel): void { + ContextMenuCommands.openFromEvent(event, ({onClose}) => ( + + )); +} diff --git a/fluxer_app/src/features/forum/components/ForumPostTagsModal.tsx b/fluxer_app/src/features/forum/components/ForumPostTagsModal.tsx new file mode 100644 index 000000000..4c3ce2517 --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumPostTagsModal.tsx @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as Modal from '@app/features/app/components/dialogs/Modal'; +import Channels from '@app/features/channel/state/Channels'; +import * as ForumCommands from '@app/features/forum/commands/ForumCommands'; +import {ForumTagPicker} from '@app/features/forum/components/ForumTagPicker'; +import {getPostForum} from '@app/features/forum/utils/ForumChannelUtils'; +import {reportForumError} from '@app/features/forum/utils/ForumErrors'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {Button} from '@app/features/ui/button/Button'; +import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; +import {useLingui} from '@lingui/react/macro'; +import {observer} from 'mobx-react-lite'; +import {useEffect, useState} from 'react'; + +export const ForumPostTagsModal = observer(({postId}: {postId: string}) => { + const {i18n} = useLingui(); + const post = Channels.getChannel(postId); + const forum = post ? getPostForum(post) : undefined; + const [selected, setSelected] = useState>(() => [...(post?.appliedTags ?? [])]); + const [submitting, setSubmitting] = useState(false); + const missing = !post || !forum; + useEffect(() => { + if (missing) ModalCommands.pop(); + }, [missing]); + if (!post || !forum) return null; + const handleSave = async () => { + setSubmitting(true); + try { + await ForumCommands.setPostTags(post, selected); + ModalCommands.pop(); + } catch (error) { + reportForumError(i18n, error); + } finally { + setSubmitting(false); + } + }; + return ( + + + + + + + + + + + ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumTagPicker.tsx b/fluxer_app/src/features/forum/components/ForumTagPicker.tsx new file mode 100644 index 000000000..1644f2fbc --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumTagPicker.tsx @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import styles from '@app/features/forum/components/Forum.module.css'; +import {ForumTagPill} from '@app/features/forum/components/ForumTagPill'; +import {getForumTags, isTagRequired} from '@app/features/forum/utils/ForumChannelUtils'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {canUseTag} from '@app/features/forum/utils/ForumPermissions'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {Button} from '@app/features/ui/button/Button'; +import {Popout} from '@app/features/ui/popover/PopoverPopout'; +import {MAX_APPLIED_TAGS_PER_THREAD} from '@fluxer/constants/src/ThreadConstants'; +import {useLingui} from '@lingui/react/macro'; +import {TagIcon} from '@phosphor-icons/react'; +import {observer} from 'mobx-react-lite'; +import {useRef, useState} from 'react'; + +interface ForumTagPickerProps { + forum: Channel; + selected: ReadonlyArray; + onChange: (tagIds: Array) => void; +} + +export const ForumTagPicker = observer(({forum, selected, onChange}: ForumTagPickerProps) => { + const {i18n} = useLingui(); + const full = selected.length >= MAX_APPLIED_TAGS_PER_THREAD; + return ( +
+
+ {forum.availableTags.map((tag) => { + const isSelected = selected.includes(tag.id); + return ( + onChange(isSelected ? selected.filter((id) => id !== tag.id) : [...selected, tag.id])} + data-flx="forum.forum-tag-picker.forum-tag-pill.change" + /> + ); + })} +
+ + {i18n._(D.TAG_LIMIT_DESCRIPTOR, {count: MAX_APPLIED_TAGS_PER_THREAD})} + +
+ ); +}); + +const ForumTagPopoutContent = observer( + ({ + forum, + initial, + onChange, + }: { + forum: Channel; + initial: ReadonlyArray; + onChange: (tagIds: Array) => void; + }) => { + const [selected, setSelected] = useState>(initial); + return ( +
+ { + setSelected(next); + onChange(next); + }} + data-flx="forum.forum-composer-tags.forum-tag-picker" + /> +
+ ); + }, +); + +export const ForumComposerTags = observer(({forum, selected, onChange}: ForumTagPickerProps) => { + const {i18n} = useLingui(); + const chosen = getForumTags(forum, selected); + const latest = useRef({selected, onChange}); + latest.current = {selected, onChange}; + return ( +
+ ( + latest.current.onChange(next)} + data-flx="forum.forum-composer-tags.forum-tag-popout-content" + /> + )} + data-flx="forum.forum-composer-tags.popout" + > + + + {chosen.map((tag) => ( + onChange(selected.filter((id) => id !== tag.id))} + data-flx="forum.forum-composer-tags.forum-tag-pill.remove" + /> + ))} + {isTagRequired(forum) && chosen.length === 0 && ( + + {i18n._(D.TAGS_REQUIRED_DESCRIPTOR)} + + )} +
+ ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumTagPill.tsx b/fluxer_app/src/features/forum/components/ForumTagPill.tsx new file mode 100644 index 000000000..055c22350 --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumTagPill.tsx @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {FlatEmoji, UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes'; +import styles from '@app/features/forum/components/Forum.module.css'; +import type {ForumEmojiRef} from '@app/features/forum/utils/ForumChannelUtils'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {toReactionEmoji, useEmojiURL} from '@app/features/messaging/utils/ReactionUtils'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; +import {useLingui} from '@lingui/react/macro'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; + +export function forumEmojiFromPicker(emoji: FlatEmoji): ForumEmojiRef { + const reaction = toReactionEmoji(emoji as UnicodeEmoji); + return reaction.id ? {emoji_id: reaction.id, emoji_name: null} : {emoji_id: null, emoji_name: reaction.name}; +} + +export const ForumEmoji = observer(({emoji, className}: {emoji: ForumEmojiRef; className?: string}) => { + const url = useEmojiURL({ + emoji: {id: emoji.emoji_id, name: emoji.emoji_name ?? 'emoji'}, + size: 32, + }); + if (!url || (emoji.emoji_id == null && emoji.emoji_name == null)) return null; + return ( + {emoji.emoji_name + ); +}); + +interface ForumTagPillProps { + tag: {id: string; name: string; moderated: boolean} & ForumEmojiRef; + selected?: boolean; + disabled?: boolean; + onClick?: () => void; +} + +export const ForumTagPill = observer(({tag, selected = false, disabled = false, onClick}: ForumTagPillProps) => { + const {i18n} = useLingui(); + const content = ( + <> + + + {tag.name} + + + ); + if (!onClick) { + return ( + + {content} + + ); + } + const button = ( + + + + ); + if (!tag.moderated) return button; + return ( + + {button} + + ); +}); diff --git a/fluxer_app/src/features/forum/components/ForumToolbar.tsx b/fluxer_app/src/features/forum/components/ForumToolbar.tsx new file mode 100644 index 000000000..036049d04 --- /dev/null +++ b/fluxer_app/src/features/forum/components/ForumToolbar.tsx @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import styles from '@app/features/forum/components/Forum.module.css'; +import {ForumEmoji, ForumTagPill} from '@app/features/forum/components/ForumTagPill'; +import {getForumPostListView} from '@app/features/forum/hooks/useForumPosts'; +import ForumPosts from '@app/features/forum/state/ForumPosts'; +import {isMediaChannel} from '@app/features/forum/utils/ForumChannelUtils'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import {canCreatePost} from '@app/features/forum/utils/ForumPermissions'; +import {CLEAR_SEARCH_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import {remFromPx} from '@app/features/theme/layout/RemFromPx'; +import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet'; +import {Button} from '@app/features/ui/button/Button'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import {KeybindHint} from '@app/features/ui/keybind_hint/KeybindHint'; +import {MenuBottomSheet, type MenuGroupType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; +import {Popout} from '@app/features/ui/popover/PopoverPopout'; +import {RadioGroup} from '@app/features/ui/radio_group/RadioGroup'; +import MobileLayout from '@app/features/ui/state/MobileLayout'; +import { + ForumLayoutTypes, + ForumSortOrderTypes, + THREAD_SEARCH_NAME_MAX_LENGTH, +} from '@fluxer/constants/src/ThreadConstants'; +import type {I18n} from '@lingui/core'; +import {useLingui} from '@lingui/react/macro'; +import {CaretDownIcon, MagnifyingGlassIcon, PlusIcon, SortAscendingIcon, TagIcon, XIcon} from '@phosphor-icons/react'; +import {clsx} from 'clsx'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; +import {useState} from 'react'; + +const ForumSortViewPanel = observer(({forum, sheet = false}: {forum: Channel; sheet?: boolean}) => { + const {i18n} = useLingui(); + return ( +
+
+

+ {i18n._(D.SORT_BY_DESCRIPTOR)} +

+ ForumPosts.setSortOrder(forum, value)} + options={[ + {value: ForumSortOrderTypes.LATEST_ACTIVITY, name: i18n._(D.SORT_RECENT_ACTIVITY_DESCRIPTOR)}, + {value: ForumSortOrderTypes.CREATION_TIME, name: i18n._(D.SORT_CREATION_DATE_DESCRIPTOR)}, + ]} + data-flx="forum.forum-toolbar.sort-panel.sort-radio-group" + /> +
+ {!isMediaChannel(forum) && ( +
+

+ {i18n._(D.VIEW_AS_DESCRIPTOR)} +

+ ForumPosts.setLayout(forum, value)} + options={[ + {value: ForumLayoutTypes.LIST, name: i18n._(D.LAYOUT_LIST_DESCRIPTOR)}, + {value: ForumLayoutTypes.GRID, name: i18n._(D.LAYOUT_GALLERY_DESCRIPTOR)}, + ]} + data-flx="forum.forum-toolbar.sort-panel.view-radio-group" + /> +
+ )} +
+ ); +}); + +function buildTagFilterGroups(i18n: I18n, forum: Channel): Array { + const selected = ForumPosts.getTagFilter(forum.id); + const groups: Array = [ + { + items: forum.availableTags.map((tag) => ({ + label: tag.name, + icon: + tag.emoji_id || tag.emoji_name ? ( + + ) : undefined, + checked: selected.includes(tag.id), + onChange: () => ForumPosts.toggleTag(forum, tag.id), + })), + }, + ]; + if (selected.length > 0) { + groups.push({items: [{label: i18n._(D.CLEAR_TAGS_DESCRIPTOR), onClick: () => ForumPosts.setTagFilter(forum, [])}]}); + } + return groups; +} + +const TagFilterRow = observer(({forum}: {forum: Channel}) => { + const {i18n} = useLingui(); + const selected = ForumPosts.getTagFilter(forum.id); + return ( +
+ {forum.availableTags.map((tag) => ( + ForumPosts.toggleTag(forum, tag.id)} + data-flx="forum.forum-toolbar.tag-filter-row.forum-tag-pill.toggle-tag" + /> + ))} + {selected.length > 0 && ( + + + + )} +
+ ); +}); + +const SEARCH_STATUS_ID_SUFFIX = 'forum-search-status'; +const CREATE_POST_COMBO = {key: 'Enter', shift: true}; + +const SearchStatus = observer(({forum}: {forum: Channel}) => { + const {i18n} = useLingui(); + const view = getForumPostListView(forum); + const pending = !view.list.loaded || view.list.loading || view.list.indexing; + if (pending) return <>{i18n._(D.SEARCHING_POSTS_DESCRIPTOR)}; + if (view.posts.length === 0) return <>{i18n._(D.NO_MATCHING_POSTS_SHORT_DESCRIPTOR)}; + return <>{i18n._(D.SEARCH_RESULT_COUNT_DESCRIPTOR, {count: view.posts.length})}; +}); + +export const ForumSearchCard = observer(({forum, onNewPost}: {forum: Channel; onNewPost: (title: string) => void}) => { + const {i18n} = useLingui(); + const query = ForumPosts.getQuery(forum.id); + const searching = query.trim().length > 0; + const canPost = canCreatePost(forum); + const statusId = `${forum.id}-${SEARCH_STATUS_ID_SUFFIX}`; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' || !event.shiftKey || !canPost) return; + event.preventDefault(); + onNewPost(query); + }; + return ( +
+
+ {ForumPosts.searchUnavailable ? ( + + ) : ( + <> + + ForumPosts.setQuery(forum, event.target.value)} + onKeyDown={handleKeyDown} + placeholder={i18n._(canPost ? D.SEARCH_FOR_POSTS_DESCRIPTOR : D.SEARCH_POSTS_DESCRIPTOR)} + aria-label={i18n._(D.SEARCH_POSTS_DESCRIPTOR)} + aria-describedby={searching ? statusId : undefined} + autoComplete="off" + data-flx="forum.forum-toolbar.search-input.set-query" + /> + + )} + {searching && ( + + + + )} + {canPost && ( + + )} +
+ {searching && ( +
+ + + + + + + {canPost && !MobileLayout.enabled && ( + + + {i18n._(D.CREATE_POST_SHORTCUT_HINT_DESCRIPTOR)} + + )} +
+ )} +
+ ); +}); + +export const ForumFilterRow = observer(({forum}: {forum: Channel}) => { + const {i18n} = useLingui(); + const [sheet, setSheet] = useState<'sort' | 'tags' | null>(null); + const mobile = MobileLayout.enabled; + const tagCount = ForumPosts.getTagFilter(forum.id).length; + return ( +
+ {mobile ? ( + + ) : ( + } + data-flx="forum.forum-toolbar.sort-popout" + > + + + )} + {forum.availableTags.length > 0 && + (mobile ? ( + + ) : ( + <> + + + + ))} + setSheet(null)} + title={i18n._(D.SORT_AND_VIEW_DESCRIPTOR)} + snapPoints={[0, 1]} + initialSnap={1} + data-flx="forum.forum-toolbar.sort-bottom-sheet" + > + + + {sheet === 'tags' && ( + setSheet(null)} + title={i18n._(D.TAGS_DESCRIPTOR)} + groups={buildTagFilterGroups(i18n, forum)} + data-flx="forum.forum-toolbar.menu-bottom-sheet" + /> + )} +
+ ); +}); diff --git a/fluxer_app/src/features/forum/components/settings/ForumOverviewTab.tsx b/fluxer_app/src/features/forum/components/settings/ForumOverviewTab.tsx new file mode 100644 index 000000000..002bce01f --- /dev/null +++ b/fluxer_app/src/features/forum/components/settings/ForumOverviewTab.tsx @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {EXAMPLE_GENERAL_CHANNEL_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit'; +import ChannelOverviewTab from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab'; +import overviewStyles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css'; +import {MatureContentSection} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/MatureContentSection'; +import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow'; +import {SlowmodeControl} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl'; +import { + CHANNEL_OVERVIEW_TAB_ID, + type FormInputs, +} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared'; +import type {Channel} from '@app/features/channel/models/Channel'; +import Channels from '@app/features/channel/state/Channels'; +import {EmojiPickerPopout} from '@app/features/emoji/components/popouts/EmojiPickerPopout'; +import * as ForumCommands from '@app/features/forum/commands/ForumCommands'; +import styles from '@app/features/forum/components/Forum.module.css'; +import {ForumEmoji, forumEmojiFromPicker} from '@app/features/forum/components/ForumTagPill'; +import {ForumTagSettings} from '@app/features/forum/components/settings/ForumTagSettings'; +import { + type ForumEmojiRef, + getDefaultLayout, + getDefaultReaction, + getDefaultSortOrder, + hidesMediaDownloads, + isMediaChannel, + isTagRequired, +} from '@app/features/forum/utils/ForumChannelUtils'; +import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; +import Guilds from '@app/features/guild/state/Guilds'; +import Permission from '@app/features/permissions/state/Permission'; +import {ThreadDefaultsSection} from '@app/features/threads/components/ThreadDefaultsSection'; +import ThreadGuilds from '@app/features/threads/state/ThreadGuilds'; +import {Button} from '@app/features/ui/button/Button'; +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, Textarea} from '@app/features/ui/components/form/FormInput'; +import {Switch} from '@app/features/ui/components/form/FormSwitch'; +import FocusRing from '@app/features/ui/focus_ring/FocusRing'; +import {Popout} from '@app/features/ui/popover/PopoverPopout'; +import {RadioGroup} from '@app/features/ui/radio_group/RadioGroup'; +import {useRemoteFormReset} from '@app/lib/forms/RemoteFormReset'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants'; +import { + ChannelFlags, + DEFAULT_THREAD_AUTO_ARCHIVE_DURATION, + FORUM_TOPIC_MAX_LENGTH, + type ForumLayoutType, + ForumLayoutTypes, + type ForumSortOrderType, + ForumSortOrderTypes, +} from '@fluxer/constants/src/ThreadConstants'; +import {useLingui} from '@lingui/react/macro'; +import {SmileyIcon} from '@phosphor-icons/react'; +import {observer} from 'mobx-react-lite'; +import {useCallback, useEffect} from 'react'; +import {Controller, type UseFormReturn, useForm} from 'react-hook-form'; + +interface ForumFormInputs extends FormInputs { + default_reaction: ForumEmojiRef | null; + default_sort_order: ForumSortOrderType; + default_forum_layout: ForumLayoutType; + require_tag: boolean; + hide_media_download: boolean; +} + +function remoteValuesFor(forum: Channel): ForumFormInputs { + return { + name: forum.name ?? '', + topic: forum.topic ?? '', + url: '', + slowmode: forum.rateLimitPerUser, + nsfw_override: forum.nsfwOverride, + content_warning_level: forum.contentWarningLevel ?? ContentWarningLevel.INHERIT, + content_warning_text: forum.contentWarningText ?? '', + rtc_region: null, + default_auto_archive_duration: forum.defaultAutoArchiveDuration ?? DEFAULT_THREAD_AUTO_ARCHIVE_DURATION, + default_thread_rate_limit_per_user: forum.defaultThreadRateLimitPerUser, + default_reaction: getDefaultReaction(forum), + default_sort_order: getDefaultSortOrder(forum), + default_forum_layout: getDefaultLayout(forum), + require_tag: isTagRequired(forum), + hide_media_download: hidesMediaDownloads(forum), + }; +} + +const DefaultReactionField = observer(({form, forum}: {form: UseFormReturn; forum: Channel}) => { + const {i18n} = useLingui(); + return ( + + ( +
+ ( + field.onChange(forumEmojiFromPicker(emoji))} + onClose={onClose} + data-flx="forum.settings.forum-overview-tab.default-reaction-field.emoji-picker-popout" + /> + )} + data-flx="forum.settings.forum-overview-tab.default-reaction-field.popout" + > + + + + + {field.value && ( + + )} +
+ )} + data-flx="forum.settings.forum-overview-tab.default-reaction-field.controller" + /> +
+ ); +}); + +const ForumOverviewForm = observer(({forum}: {forum: Channel}) => { + const {i18n} = useLingui(); + const guild = forum.guildId ? Guilds.getGuild(forum.guildId) : null; + const media = isMediaChannel(forum); + const form = useForm({defaultValues: remoteValuesFor(forum)}); + const sharedForm = form as unknown as UseFormReturn; + const {resetToRemoteValues, commitRemoteValues} = useRemoteFormReset({ + form, + identityKey: forum.id, + remoteValues: remoteValuesFor(forum), + }); + const onSubmit = useCallback( + async (data: ForumFormInputs) => { + const dirty = form.formState.dirtyFields; + const patch: ForumCommands.ForumChannelPatch & Record = {}; + if (dirty.name) patch.name = data.name; + if (dirty.topic) patch.topic = data.topic?.trim() ? data.topic : null; + if (dirty.slowmode) patch.rate_limit_per_user = data.slowmode; + if (dirty.default_auto_archive_duration) patch.default_auto_archive_duration = data.default_auto_archive_duration; + if (dirty.default_thread_rate_limit_per_user) { + patch.default_thread_rate_limit_per_user = data.default_thread_rate_limit_per_user; + } + if (dirty.default_reaction) patch.default_reaction_emoji = data.default_reaction; + if (dirty.default_sort_order) patch.default_sort_order = data.default_sort_order; + if (dirty.default_forum_layout && !media) patch.default_forum_layout = data.default_forum_layout; + if (dirty.require_tag || dirty.hide_media_download) { + let flags = forum.flags & ~(ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS); + if (data.require_tag) flags |= ChannelFlags.REQUIRE_TAG; + if (media && data.hide_media_download) flags |= ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS; + patch.flags = flags; + } + if (dirty.nsfw_override) patch.nsfw_override = data.nsfw_override; + if (dirty.content_warning_level) patch.content_warning_level = data.content_warning_level; + if (dirty.content_warning_text) { + const trimmed = (data.content_warning_text ?? '').trim(); + patch.content_warning_text = trimmed.length > 0 ? trimmed : null; + } + if (Object.keys(patch).length > 0) { + await ForumCommands.updateForumChannel(forum, patch); + } + commitRemoteValues(data); + ToastCommands.createToast({type: 'success', children: i18n._(D.FORUM_SETTINGS_SAVED_DESCRIPTOR)}); + }, + [commitRemoteValues, form, forum, i18n, media], + ); + const {handleSubmit: handleSave} = useFormSubmit({form, onSubmit, defaultErrorField: 'name'}); + const isDirty = form.formState.isDirty; + useEffect(() => { + UnsavedChangesCommands.setUnsavedChanges(CHANNEL_OVERVIEW_TAB_ID, isDirty); + }, [isDirty]); + useEffect(() => { + UnsavedChangesCommands.setTabData(CHANNEL_OVERVIEW_TAB_ID, { + onReset: resetToRemoteValues, + onSave: handleSave, + isSubmitting: form.formState.isSubmitting, + }); + }, [resetToRemoteValues, handleSave, form.formState.isSubmitting]); + useEffect(() => () => UnsavedChangesCommands.clearUnsavedChanges(CHANNEL_OVERVIEW_TAB_ID), []); + return ( +
+
+
+ + ( +