diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 1c9df85d2..07f12cb52 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -17262,6 +17262,7 @@ "MAX_GROUP_DM_RECIPIENTS", "MAX_GROUP_DMS", "GROUP_DM_RECIPIENTS_NOT_ADDABLE", + "GROUP_DM_MATURE_CONTENT_INELIGIBLE", "MAX_GUILD_CHANNELS", "MAX_GUILD_MEMBERS", "MAX_GUILD_ROLES", @@ -17558,6 +17559,7 @@ "You've reached the maximum of {count, plural, one {# group DM recipient} other {# group DM recipients}}.", "You've reached the maximum of {count, plural, one {# group DM} other {# group DMs}}.", "One or more selected users can't be added to this group DM.", + "Not everyone in this group is eligible for mature content.", "You've reached the maximum of {count, plural, one {# community channel} other {# community channels}}.", "You've reached the maximum of {count, plural, one {# community member} other {# community members}}.", "You've reached the maximum of {count, plural, one {# community role} other {# community roles}}.", diff --git a/fluxer_api/src/api/channel/ChannelMappers.ts b/fluxer_api/src/api/channel/ChannelMappers.ts index b7dc13278..cdbcc8c72 100644 --- a/fluxer_api/src/api/channel/ChannelMappers.ts +++ b/fluxer_api/src/api/channel/ChannelMappers.ts @@ -165,6 +165,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse { icon: channel.iconHash ?? null, owner_id: channel.ownerId ? channel.ownerId.toString() : null, nicks: nicknameMap.size > 0 ? nicks : undefined, + nsfw: channel.isNsfw, }; } diff --git a/fluxer_api/src/api/channel/services/ChannelDataService.ts b/fluxer_api/src/api/channel/services/ChannelDataService.ts index 7d84b10db..85d2f9b6b 100644 --- a/fluxer_api/src/api/channel/services/ChannelDataService.ts +++ b/fluxer_api/src/api/channel/services/ChannelDataService.ts @@ -179,6 +179,7 @@ export class ChannelDataService { icon: groupDmData.icon !== undefined ? groupDmData.icon : undefined, ownerId: groupDmData.owner_id ? createUserID(groupDmData.owner_id) : undefined, nicks: groupDmData.nicks, + nsfw: groupDmData.nsfw ?? undefined, requestCache, }); } diff --git a/fluxer_api/src/api/channel/services/channel_data/GroupDmUpdateService.ts b/fluxer_api/src/api/channel/services/channel_data/GroupDmUpdateService.ts index a79ad55d9..54e0f5956 100644 --- a/fluxer_api/src/api/channel/services/channel_data/GroupDmUpdateService.ts +++ b/fluxer_api/src/api/channel/services/channel_data/GroupDmUpdateService.ts @@ -12,7 +12,9 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService' import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Channel} from '@app/api/models/Channel'; import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils'; import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import {GroupDmMatureContentIneligibleError} from '@fluxer/errors/src/domains/channel/GroupDmMatureContentIneligibleError'; import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; @@ -37,6 +39,7 @@ export class GroupDmUpdateService { icon, ownerId, nicks, + nsfw, requestCache, }: { userId: UserID; @@ -45,6 +48,7 @@ export class GroupDmUpdateService { icon?: string | null; ownerId?: UserID; nicks?: Record | null; + nsfw?: boolean; requestCache: RequestCache; }): Promise { const channel = await this.channelRepository.channelData.findUnique(channelId); @@ -113,6 +117,20 @@ export class GroupDmUpdateService { updates.nicks = updatedNicknames.size > 0 ? (updatedNicknames as Map) : null; } } + if (nsfw !== undefined && nsfw !== channel.isNsfw) { + if (channel.ownerId !== userId) { + throw new MissingPermissionsError(); + } + if (nsfw) { + const recipients = await Promise.all( + Array.from(channel.recipientIds).map((id) => this.userRepository.findUnique(id)), + ); + if (recipients.some((recipient) => !recipient || !canUserAccessNsfwContent(recipient))) { + throw new GroupDmMatureContentIneligibleError(); + } + } + updates.nsfw = nsfw; + } let iconHash: string | null = null; if (icon !== undefined) { iconHash = await this.avatarService.uploadAvatar({ diff --git a/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts b/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts index af3d336a8..127a98612 100644 --- a/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts +++ b/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts @@ -24,6 +24,7 @@ import type {User} from '@app/api/models/User'; import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; import {assertAccountNotLimited} from '@app/api/user/AccountLimit'; import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils'; import {UserPermissionUtils} from '@app/api/utils/UserPermissionUtils'; import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; @@ -36,6 +37,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha import {CannotRemoveOtherRecipientsError} from '@fluxer/errors/src/domains/core/CannotRemoveOtherRecipientsError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; +import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError'; import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError'; export class GroupDmOperationsService { @@ -84,6 +86,9 @@ export class GroupDmOperationsService { userId, targetId: recipientId, }); + if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) { + throw new MissingAccessError(); + } const {channel: updatedChannel} = await this.addRecipientViaInviteWithResult({ channelId, recipientId, @@ -122,6 +127,11 @@ export class GroupDmOperationsService { return updatedChannel; } + private async canJoinMatureGroup(userId: UserID): Promise { + const user = await this.userRepository.findUnique(userId); + return user != null && canUserAccessNsfwContent(user); + } + private async assertInviterNotLimited(userId: UserID): Promise { const user = await this.userRepository.findUnique(userId); if (user) assertAccountNotLimited(user); @@ -169,6 +179,9 @@ export class GroupDmOperationsService { if (channel.recipientIds.has(recipientId)) { return {channel, recipientAdded: false}; } + if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) { + throw new NsfwContentRequiresAgeVerificationError(); + } const inviterUser = inviterId ? await this.userRepository.findUnique(inviterId) : null; const fallbackLimit = MAX_GROUP_DM_RECIPIENTS; const recipientLimit = this.resolveLimitForUser(inviterUser ?? null, 'max_group_dm_recipients', fallbackLimit); diff --git a/fluxer_api/src/api/channel/services/message/MessageContentService.ts b/fluxer_api/src/api/channel/services/message/MessageContentService.ts index c718fb8f0..e867ed835 100644 --- a/fluxer_api/src/api/channel/services/message/MessageContentService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageContentService.ts @@ -9,7 +9,7 @@ import * as EmojiUtils from '@app/api/utils/EmojiUtils'; import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants'; import {GuildExplicitContentFilterTypes, GuildFeatures, GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants'; import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; -import {SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants'; +import {RelationshipTypes, SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; @@ -40,6 +40,30 @@ export class MessageContentService { }); } + async resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise { + if (channel.type !== ChannelTypes.DM) { + return undefined; + } + const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId); + if (recipientIds.length !== 1) { + return undefined; + } + const recipientId = recipientIds[0]; + const [senderSettings, recipientSettings, friendship] = await Promise.all([ + this.userRepository.findSettings(senderId), + this.userRepository.findSettings(recipientId), + this.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND), + ]); + const areFriends = friendship != null; + const senderFilterLevel = areFriends + ? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW) + : (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK); + const recipientFilterLevel = areFriends + ? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW) + : (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK); + return {senderFilterLevel, recipientFilterLevel}; + } + isNSFWContentAllowed(params: { channel?: Channel; guild?: GuildResponse | null; @@ -61,6 +85,9 @@ export class MessageContentService { if (channel?.type === ChannelTypes.DM_PERSONAL_NOTES) { return true; } + if (channel?.type === ChannelTypes.GROUP_DM && channel.isNsfw) { + return true; + } if (!guild) { if (dmNsfwContext) { const eitherBlocks = diff --git a/fluxer_api/src/api/channel/services/message/MessageEditService.ts b/fluxer_api/src/api/channel/services/message/MessageEditService.ts index d55ecc7dc..8721b83e5 100644 --- a/fluxer_api/src/api/channel/services/message/MessageEditService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageEditService.ts @@ -175,6 +175,7 @@ export class MessageEditService { }); } const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n; + const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, userId); const updateResult = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => { if (!fresh) throw new UnknownMessageError(); return this.deps.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'author'}, () => @@ -190,6 +191,7 @@ export class MessageEditService { isBot: user?.isBot, isBugHunterBot, locale: user?.locale, + dmNsfwContext, }), ); }); diff --git a/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts b/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts index df2b2d8a8..79f2f4bb3 100644 --- a/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts +++ b/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts @@ -158,6 +158,10 @@ export class MessagePersistenceService { return resolveNsfwScopeChannel(channel, (channelId) => this.channelRepository.channelData.findUnique(channelId)); } + resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise { + return this.contentService.resolveDmNsfwContext(channel, senderId); + } + getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver { return this.embedAttachmentResolver; } @@ -402,6 +406,7 @@ export class MessagePersistenceService { isBot?: boolean; isBugHunterBot?: boolean; locale?: string | null; + dmNsfwContext?: DmNsfwContext; }): Promise { const {message, messageId, data, channel, guild, member} = params; if (message.messageSnapshots && message.messageSnapshots.length > 0) { @@ -412,6 +417,7 @@ export class MessagePersistenceService { guild, member, isBot: params.isBot, + dmNsfwContext: params.dmNsfwContext, }); const updatedRowData = {...message.toRow()}; let hasChanges = false; diff --git a/fluxer_api/src/api/channel/services/message/MessageSendService.ts b/fluxer_api/src/api/channel/services/message/MessageSendService.ts index 13d48b29f..750f95ac8 100644 --- a/fluxer_api/src/api/channel/services/message/MessageSendService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageSendService.ts @@ -19,7 +19,6 @@ import type {AuthenticatedChannel} from '@app/api/channel/services/Authenticated import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation'; import {emitMessageCreated} from '@app/api/channel/services/message/MessageActivity'; import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService'; -import type {DmNsfwContext} from '@app/api/channel/services/message/MessageContentService'; import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService'; import type {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/MessageEmbedAttachmentResolver'; import { @@ -69,12 +68,7 @@ import { } from '@fluxer/constants/src/ChannelConstants'; import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants'; import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils'; -import { - DELETED_USER_ID, - RelationshipTypes, - SensitiveMediaFilterLevel, - UserFlags, -} from '@fluxer/constants/src/UserConstants'; +import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError'; @@ -193,33 +187,6 @@ export class MessageSendService { return includeDefault ? {includeDefault} : null; } - private async buildDmNsfwContext(channel: Channel, senderId: UserID): Promise { - if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) { - return undefined; - } - if (channel.type !== ChannelTypes.DM) { - return undefined; - } - const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId); - if (recipientIds.length !== 1) { - return undefined; - } - const recipientId = recipientIds[0]; - const [senderSettings, recipientSettings, friendship] = await Promise.all([ - this.deps.userRepository.findSettings(senderId), - this.deps.userRepository.findSettings(recipientId), - this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND), - ]); - const areFriends = friendship != null; - const senderFilterLevel = areFriends - ? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW) - : (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK); - const recipientFilterLevel = areFriends - ? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW) - : (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK); - return {senderFilterLevel, recipientFilterLevel}; - } - private attachmentsToProcess(attachments?: Array): Array | undefined { if (!attachments) return undefined; const processed = attachments.filter( @@ -1033,7 +1000,7 @@ export class MessageSendService { channelId, data, }); - const dmNsfwContext = guild ? undefined : await this.buildDmNsfwContext(channel, user.id); + const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, user.id); const messageId = forumStarter ? channelIdToMessageId(channel.id) : await this.generateMessageId(channel); let mentionData: SendMentionData | undefined; const shouldExtractMentions = diff --git a/fluxer_api/src/api/channel/tests/GroupDmMatureContent.test.ts b/fluxer_api/src/api/channel/tests/GroupDmMatureContent.test.ts new file mode 100644 index 000000000..215674a26 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/GroupDmMatureContent.test.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {MessageContentService} from '@app/api/channel/services/message/MessageContentService'; +import {createChannelInvite, createFriendship, createGroupDmChannel} from '@app/api/channel/tests/ChannelTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +describe('group dm mature content', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + async function setup() { + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const other = await createTestAccount(harness); + await createFriendship(harness, owner, member); + await createFriendship(harness, owner, other); + await ensureSessionStarted(harness, owner.token); + const group = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]); + return {owner, member, other, groupId: group.id}; + } + + async function joinAsMinor(groupId: string, owner: TestAccount, status: number, code?: string) { + const minor = await createTestAccount(harness, {dateOfBirth: '2012-01-01'}); + const invite = await createChannelInvite(harness, owner.token, groupId); + await createBuilder(harness, minor.token).post(`/invites/${invite.code}`).body({}).expect(status, code).execute(); + } + + function setNsfw(account: TestAccount, channelId: string, nsfw: boolean, status = 200, code?: string) { + return createBuilder(harness, account.token) + .patch(`/channels/${channelId}`) + .body({type: ChannelTypes.GROUP_DM, nsfw}) + .expect(status, code) + .execute(); + } + + it('lets the owner mark the group and allows mature media once marked', async () => { + const s = await setup(); + const scope = vi.spyOn(MessageContentService.prototype, 'isNSFWContentAllowed'); + await sendMessage(harness, s.owner.token, s.groupId, 'before'); + expect(scope.mock.results.at(-1)?.value).toBe(false); + const updated = await setNsfw(s.owner, s.groupId, true); + expect(updated.nsfw).toBe(true); + await sendMessage(harness, s.owner.token, s.groupId, 'after'); + expect(scope.mock.results.at(-1)?.value).toBe(true); + const cleared = await setNsfw(s.owner, s.groupId, false); + expect(cleared.nsfw).toBe(false); + }); + + it('rejects the toggle from a member who is not the owner', async () => { + const s = await setup(); + await setNsfw(s.member, s.groupId, true, 403); + }); + + it('refuses to mark a group that includes a minor', async () => { + const s = await setup(); + await joinAsMinor(s.groupId, s.owner, 200); + await setNsfw(s.owner, s.groupId, true, 400, APIErrorCodes.GROUP_DM_MATURE_CONTENT_INELIGIBLE); + }); + + it('refuses to let a minor join a marked group', async () => { + const s = await setup(); + await setNsfw(s.owner, s.groupId, true); + await joinAsMinor(s.groupId, s.owner, 403, APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED); + }); +}); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 960f704ac..5626929d6 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -32324,6 +32324,10 @@ "nicks": { "description": "Custom nicknames for users in this group DM", "anyOf": [{"$ref": "#/components/schemas/ChannelNicknameOverrides"}, {"type": "null"}] + }, + "nsfw": { + "description": "Whether the group DM is marked for mature content (owner only)", + "type": ["boolean", "null"] } } }, @@ -39725,6 +39729,7 @@ "MAX_GROUP_DM_RECIPIENTS", "MAX_GROUP_DMS", "GROUP_DM_RECIPIENTS_NOT_ADDABLE", + "GROUP_DM_MATURE_CONTENT_INELIGIBLE", "MAX_GUILD_CHANNELS", "MAX_GUILD_MEMBERS", "MAX_GUILD_ROLES", @@ -40021,6 +40026,7 @@ "You've reached the maximum of {count, plural, one {# group DM recipient} other {# group DM recipients}}.", "You've reached the maximum of {count, plural, one {# group DM} other {# group DMs}}.", "One or more selected users can't be added to this group DM.", + "Not everyone in this group is eligible for mature content.", "You've reached the maximum of {count, plural, one {# community channel} other {# community channels}}.", "You've reached the maximum of {count, plural, one {# community member} other {# community members}}.", "You've reached the maximum of {count, plural, one {# community role} other {# community roles}}.", diff --git a/fluxer_app/src/features/channel/components/modals/EditGroupBottomSheet.tsx b/fluxer_app/src/features/channel/components/modals/EditGroupBottomSheet.tsx index 21d7a7bd2..414bafaa9 100644 --- a/fluxer_app/src/features/channel/components/modals/EditGroupBottomSheet.tsx +++ b/fluxer_app/src/features/channel/components/modals/EditGroupBottomSheet.tsx @@ -6,9 +6,11 @@ import { STATIC_IMAGE_FORMATS, } from '@app/features/app/config/I18nDisplayConstants'; import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit'; +import Authentication from '@app/features/auth/state/Authentication'; import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands'; import {showChannelErrorModal} from '@app/features/channel/components/alerts/ChannelErrorModalUtils'; import styles from '@app/features/channel/components/modals/EditGroupBottomSheet.module.css'; +import {GroupMatureContentSwitch} from '@app/features/channel/components/modals/GroupMatureContentSwitch'; import Channels from '@app/features/channel/state/Channels'; import {AssetCropModal, AssetType} from '@app/features/expressions/components/modals/AssetCropModal'; import {openAssetSourceModal} from '@app/features/expressions/components/modals/AssetSourceModal'; @@ -42,7 +44,7 @@ import {ArrowLeftIcon, PlusIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useMemo, useState} from 'react'; -import {useForm} from 'react-hook-form'; +import {Controller, useForm} from 'react-hook-form'; const ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR = msg({ message: 'Icon file is too large. Choose a file smaller than {imageMaxSizeLabel}.', @@ -94,6 +96,7 @@ const CHANGE_ICON_DESCRIPTOR = msg({ interface FormInputs { icon?: string | null; name: string; + nsfw: boolean; } interface EditGroupBottomSheetProps { @@ -105,12 +108,13 @@ interface EditGroupBottomSheetProps { export const EditGroupBottomSheet: React.FC = observer(({isOpen, onClose, channelId}) => { const {i18n} = useLingui(); const channel = Channels.getChannel(channelId); + const isOwner = channel?.ownerId === Authentication.currentUserId; const [hasClearedIcon, setHasClearedIcon] = useState(false); const [previewIconUrl, setPreviewIconUrl] = useState(null); const form = useForm({ - defaultValues: useMemo(() => ({name: channel?.name || ''}), [channel]), + defaultValues: useMemo(() => ({name: channel?.name || '', nsfw: channel?.nsfw ?? false}), [channel]), }); - const remoteValues: FormInputs | null = channel ? {name: channel.name || ''} : null; + const remoteValues: FormInputs | null = channel ? {name: channel.name || '', nsfw: channel.nsfw} : null; const {commitRemoteValues} = useRemoteFormReset({ form, identityKey: channelId, @@ -220,18 +224,21 @@ export const EditGroupBottomSheet: React.FC = observe }, [form]); const onSubmit = useCallback( async (data: FormInputs) => { - const updateData: {icon?: string | null; name: string} = {name: data.name}; + const updateData: {icon?: string | null; name: string; nsfw?: boolean} = {name: data.name}; + if (isOwner && data.nsfw !== channel?.nsfw) { + updateData.nsfw = data.nsfw; + } assignTransientUploadFieldMutation(updateData, 'icon', { value: data.icon, previewUrl: previewIconUrl, hasCleared: hasClearedIcon, }); const newChannel = await ChannelCommands.update(channelId, updateData); - commitRemoteValues({name: newChannel.name || data.name}); + commitRemoteValues({name: newChannel.name || data.name, nsfw: newChannel.nsfw ?? data.nsfw}); ToastCommands.createToast({type: 'success', children: Group updated}); onClose(); }, - [channelId, commitRemoteValues, onClose, previewIconUrl, hasClearedIcon], + [channel, channelId, commitRemoteValues, isOwner, onClose, previewIconUrl, hasClearedIcon], ); const {handleSubmit, isSubmitting} = useFormSubmit({ form, @@ -356,6 +363,19 @@ export const EditGroupBottomSheet: React.FC = observe maxLength={100} error={form.formState.errors.name?.message} /> + {isOwner && ( + ( + + )} + /> + )}