From 81d69c41f5d860417621422ba29582931dd29b97 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 3 Oct 2026 13:12:30 +0200 Subject: [PATCH] feat(discovery): resolve message links into discoverable guilds (#3159) --- .../controllers/GuildDiscoveryController.ts | 30 +++- .../guild/services/GuildDiscoveryService.ts | 45 ++++- .../tests/DiscoverySearchAndJoin.test.ts | 66 ++++++- .../src/api/middleware/ServiceSingletons.ts | 1 + fluxer_api/src/api/openapi/openapi.json | 100 +++++++++++ .../DiscoveryRateLimitConfig.ts | 4 + .../src/features/app/constants/Endpoints.ts | 2 + .../discovery/commands/DiscoveryCommands.ts | 10 ++ .../commands/DiscoveryJoinCommands.tsx | 11 +- .../state/DiscoveryChannelPreviews.ts | 55 ++++++ .../markdown/renderers/LinkRenderer.tsx | 167 +++++++++++++++++- .../src/domains/common/CommonParamSchemas.ts | 4 + .../domains/guild/GuildDiscoverySchemas.ts | 19 ++ 13 files changed, 504 insertions(+), 10 deletions(-) create mode 100644 fluxer_app/src/features/discovery/state/DiscoveryChannelPreviews.ts diff --git a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts index 2f37aa05e..818e284ce 100644 --- a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createGuildID} from '@app/api/BrandedTypes'; +import {createChannelID, createGuildID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes'; import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; @@ -15,12 +15,13 @@ import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstan import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError'; import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError'; import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError'; -import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import {GuildIdChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import { DiscoveryApplicationPatchRequest, DiscoveryApplicationRequest, DiscoveryApplicationResponse, DiscoveryCategoryListResponse, + DiscoveryChannelPreviewResponse, DiscoveryGuildListResponse, DiscoverySearchQuery, DiscoveryStatusResponse, @@ -100,6 +101,31 @@ export function GuildDiscoveryController(app: HonoApp) { return ctx.json(categories); }, ); + app.get( + '/discovery/guilds/:guild_id/channels/:channel_id', + RateLimitMiddleware(RateLimitConfigs.DISCOVERY_CHANNEL_PREVIEW), + LoginRequired, + DefaultUserOnly, + Validator('param', GuildIdChannelIdParam), + OpenAPI({ + operationId: 'get_discovery_channel_preview', + summary: 'Preview a channel in a discoverable guild', + description: + 'Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.', + responseSchema: DiscoveryChannelPreviewResponse, + statusCode: 200, + security: ['sessionToken', 'bearerToken'], + tags: ['Discovery'], + }), + async (ctx) => { + ensureDiscoveryEnabled(); + const {guild_id, channel_id} = ctx.req.valid('param'); + const preview = await ctx + .get('discoveryService') + .getChannelPreview(createGuildID(guild_id), createChannelID(channel_id)); + return ctx.json(preview); + }, + ); app.post( '/discovery/guilds/:guild_id/join', RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN), diff --git a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts index 66d245bc9..9b04efbda 100644 --- a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts +++ b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {GuildID, UserID} from '@app/api/BrandedTypes'; +import {type ChannelID, type GuildID, guildIdToRoleId, type UserID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; +import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository'; import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes'; import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel'; import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository'; @@ -10,6 +11,7 @@ import {contentModerationService} from '@app/api/infrastructure/ContentModeratio import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {Logger} from '@app/api/Logger'; import type {IGuildSearchService} from '@app/api/search/IGuildSearchService'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import { DISCOVERY_DEFAULT_LANGUAGE, DISCOVERY_MAX_TAGS, @@ -83,6 +85,8 @@ export abstract class IGuildDiscoveryService { abstract listByStatus(params: {status: string}): Promise>; + abstract getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise; + abstract searchDiscoverable(params: { query?: string; categoryId?: number; @@ -118,7 +122,13 @@ interface DiscoveryGuildResult { verification_level: number; } +interface DiscoveryChannelPreview { + guild: {id: string; name: string; icon: string | null}; + channel: {id: string; name: string | null; type: number}; +} + const DISCOVERY_CATEGORY_FACET = 'discoveryCategory'; +const PUBLIC_CHANNEL_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY; function toDiscoveryCategoryCounts( counts: Readonly> | undefined, @@ -143,6 +153,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService { private readonly guildRepository: IGuildRepositoryAggregate, private readonly gatewayService: IGatewayService, private readonly guildSearchService: IGuildSearchService | null, + private readonly channelDataRepository: IChannelDataRepository, ) { super(); } @@ -382,6 +393,38 @@ export class GuildDiscoveryService extends IGuildDiscoveryService { return this.discoveryRepository.listFullByStatus(params.status); } + async getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise { + const [status, guild, channel, everyoneRole] = await Promise.all([ + this.discoveryRepository.findByGuildId(guildId), + this.guildRepository.findUnique(guildId), + this.channelDataRepository.findUnique(channelId), + this.guildRepository.getRole(guildIdToRoleId(guildId), guildId), + ]); + if ( + status?.status !== DiscoveryApplicationStatus.APPROVED || + !guild || + guild.features.has(GuildFeatures.INVITES_DISABLED) || + !channel || + channel.guildId !== guildId || + !everyoneRole + ) { + throw new DiscoveryNotDiscoverableError(); + } + if ((everyoneRole.permissions & Permissions.ADMINISTRATOR) === 0n) { + const overwrite = channel.permissionOverwrites.get(everyoneRole.id); + const permissions = overwrite + ? (everyoneRole.permissions & ~overwrite.deny) | overwrite.allow + : everyoneRole.permissions; + if ((permissions & PUBLIC_CHANNEL_PERMISSIONS) !== PUBLIC_CHANNEL_PERMISSIONS) { + throw new DiscoveryNotDiscoverableError(); + } + } + return { + guild: {id: guild.id.toString(), name: guild.name, icon: guild.iconHash}, + channel: {id: channel.id.toString(), name: channel.name, type: channel.type}, + }; + } + async searchDiscoverable(params: { query?: string; categoryId?: number; diff --git a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts index 6df8b6ce1..c80612240 100644 --- a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts @@ -3,7 +3,8 @@ import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils'; import type {GuildID} from '@app/api/BrandedTypes'; import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils'; -import {createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils'; +import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils'; +import {createChannel, createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils'; import {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry'; import {getGuildRepository} from '@app/api/middleware/ServiceSingletons'; import {banUser} from '@app/api/moderation/tests/ModerationTestUtils'; @@ -15,11 +16,13 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex'; import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants'; import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants'; import type { DiscoveryApplicationResponse, DiscoveryCategoryResponse, + DiscoveryChannelPreviewResponse, DiscoveryGuildListResponse, } from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas'; import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; @@ -507,4 +510,65 @@ describe('Discovery Search and Join', () => { .execute(); }); }); + describe('channel preview', () => { + async function createListedGuild(name: string): Promise<{ownerToken: string; guildId: string; channelId: string}> { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, name); + await setGuildMemberCount(harness, guild.id, 10); + const admin = await createTestAccount(harness); + await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']); + await applyAndApprove( + harness, + owner.token, + admin.token, + guild.id, + `${name} welcomes everyone`, + DiscoveryCategories.GAMING, + ); + return {ownerToken: owner.token, guildId: guild.id, channelId: guild.system_channel_id!}; + } + test('should preview a channel that new members can read', async () => { + const {guildId, channelId} = await createListedGuild('Preview Guild'); + const viewer = await createTestAccount(harness); + const preview = await createBuilder(harness, viewer.token) + .get(`/discovery/guilds/${guildId}/channels/${channelId}`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(preview.guild.id).toBe(guildId); + expect(preview.guild.name).toBe('Preview Guild'); + expect(preview.channel.id).toBe(channelId); + }); + test('should not preview a channel hidden from everyone', async () => { + const {ownerToken, guildId} = await createListedGuild('Hidden Channel Guild'); + const hidden = await createChannel(harness, ownerToken, guildId, 'staff'); + await createPermissionOverwrite(harness, ownerToken, hidden.id, guildId, { + type: 0, + allow: '0', + deny: Permissions.VIEW_CHANNEL.toString(), + }); + const viewer = await createTestAccount(harness); + await createBuilder(harness, viewer.token) + .get(`/discovery/guilds/${guildId}/channels/${hidden.id}`) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) + .execute(); + }); + test('should not preview a channel from another guild', async () => { + const {guildId} = await createListedGuild('Listed Guild'); + const other = await createListedGuild('Other Listed Guild'); + const viewer = await createTestAccount(harness); + await createBuilder(harness, viewer.token) + .get(`/discovery/guilds/${guildId}/channels/${other.channelId}`) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) + .execute(); + }); + test('should not preview a guild that is not listed', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Unlisted Guild'); + const viewer = await createTestAccount(harness); + await createBuilder(harness, viewer.token) + .get(`/discovery/guilds/${guild.id}/channels/${guild.system_channel_id}`) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) + .execute(); + }); + }); }); diff --git a/fluxer_api/src/api/middleware/ServiceSingletons.ts b/fluxer_api/src/api/middleware/ServiceSingletons.ts index 46ebcdb69..7ef19d30a 100644 --- a/fluxer_api/src/api/middleware/ServiceSingletons.ts +++ b/fluxer_api/src/api/middleware/ServiceSingletons.ts @@ -463,6 +463,7 @@ export const getGuildDiscoveryService = singleton( getGuildRepository(), getGatewayService(), getGuildSearchService(), + getChannelRepository().channelData, ), ); export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService())); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 578111623..c559c03e0 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -5541,6 +5541,77 @@ ] } }, + "/discovery/guilds/{guild_id}/channels/{channel_id}": { + "get": { + "operationId": "get_discovery_channel_preview", + "summary": "Preview a channel in a discoverable guild", + "tags": ["Discovery"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryChannelPreviewResponse"}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "guild_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the guild", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the guild" + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the channel" + } + ] + } + }, "/discovery/guilds/{guild_id}/join": { "post": { "operationId": "join_discovery_guild", @@ -28579,6 +28650,35 @@ "required": ["url"], "additionalProperties": false }, + "DiscoveryChannelPreviewResponse": { + "type": "object", + "properties": { + "guild": { + "type": "object", + "properties": { + "id": {"description": "Guild ID", "$ref": "#/components/schemas/SnowflakeStringType"}, + "name": {"type": "string", "description": "Guild name"}, + "icon": {"description": "Guild icon hash", "type": ["string", "null"]} + }, + "required": ["id", "name", "icon"], + "additionalProperties": false, + "description": "The discoverable guild the channel belongs to" + }, + "channel": { + "type": "object", + "properties": { + "id": {"description": "Channel ID", "$ref": "#/components/schemas/SnowflakeStringType"}, + "name": {"description": "Channel name", "type": ["string", "null"]}, + "type": {"type": "number", "description": "Channel type"} + }, + "required": ["id", "name", "type"], + "additionalProperties": false, + "description": "A channel that new members can view" + } + }, + "required": ["guild", "channel"], + "additionalProperties": false + }, "DiscoveryGuildListResponse": { "type": "object", "properties": { diff --git a/fluxer_api/src/api/rate_limit_configs/DiscoveryRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/DiscoveryRateLimitConfig.ts index 149b1958a..1a890097c 100644 --- a/fluxer_api/src/api/rate_limit_configs/DiscoveryRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/DiscoveryRateLimitConfig.ts @@ -16,6 +16,10 @@ export const DiscoveryRateLimitConfigs = { bucket: 'discovery:join', config: {limit: 10, windowMs: ms('1 minute')}, } as RouteRateLimitConfig, + DISCOVERY_CHANNEL_PREVIEW: { + bucket: 'discovery:channel_preview', + config: {limit: 60, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, DISCOVERY_APPLY: { bucket: 'discovery:apply::guild_id', config: {limit: 5, windowMs: ms('1 minute')}, diff --git a/fluxer_app/src/features/app/constants/Endpoints.ts b/fluxer_app/src/features/app/constants/Endpoints.ts index 8972b8a52..b79793e76 100644 --- a/fluxer_app/src/features/app/constants/Endpoints.ts +++ b/fluxer_app/src/features/app/constants/Endpoints.ts @@ -245,6 +245,8 @@ export const Endpoints = { DISCOVERY_GUILDS: '/discovery/guilds', DISCOVERY_CATEGORIES: '/discovery/categories', DISCOVERY_JOIN: (guildId: string) => `/discovery/guilds/${guildId}/join`, + DISCOVERY_CHANNEL_PREVIEW: (guildId: string, channelId: string) => + `/discovery/guilds/${guildId}/channels/${channelId}`, GUILD_DISCOVERY: (guildId: string) => `/guilds/${guildId}/discovery`, CONNECTIONS: '/users/@me/connections', CONNECTIONS_VERIFY_AND_CREATE: '/users/@me/connections/verify', diff --git a/fluxer_app/src/features/discovery/commands/DiscoveryCommands.ts b/fluxer_app/src/features/discovery/commands/DiscoveryCommands.ts index fe65ff6a1..15b98bf27 100644 --- a/fluxer_app/src/features/discovery/commands/DiscoveryCommands.ts +++ b/fluxer_app/src/features/discovery/commands/DiscoveryCommands.ts @@ -23,6 +23,11 @@ export interface DiscoveryGuild { type DiscoveryGuildPayload = Omit & {banner?: string | null}; +export interface DiscoveryChannelPreview { + guild: {id: string; name: string; icon: string | null}; + channel: {id: string; name: string | null; type: number}; +} + interface DiscoveryCategoryCountPayload { category_type: number; count: number; @@ -115,6 +120,11 @@ export async function getCategories(): Promise> { return requestDiscoveryCategories(); } +export async function getChannelPreview(guildId: string, channelId: string): Promise { + const response = await http.get(Endpoints.DISCOVERY_CHANNEL_PREVIEW(guildId, channelId)); + return response.body; +} + export async function joinGuild(guildId: string): Promise { await http.post(Endpoints.DISCOVERY_JOIN(guildId)); logger.info('Joined guild via discovery', {guildId}); diff --git a/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx b/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx index 8d3a76f36..12b602323 100644 --- a/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx +++ b/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx @@ -112,10 +112,17 @@ function showJoinGuildErrorModal(error: unknown): void { ); } -export async function joinDiscoveryGuild(guildId: string): Promise { +export async function joinDiscoveryGuild( + guildId: string, + target?: {channelId: string; messageId?: string}, +): Promise { try { await DiscoveryCommands.joinGuild(guildId); - NavigationCommands.selectGuild(guildId); + if (target) { + NavigationCommands.selectChannel(guildId, target.channelId, target.messageId); + } else { + NavigationCommands.selectGuild(guildId); + } return true; } catch (error) { showJoinGuildErrorModal(error); diff --git a/fluxer_app/src/features/discovery/state/DiscoveryChannelPreviews.ts b/fluxer_app/src/features/discovery/state/DiscoveryChannelPreviews.ts new file mode 100644 index 000000000..b17ce2f72 --- /dev/null +++ b/fluxer_app/src/features/discovery/state/DiscoveryChannelPreviews.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {DiscoveryChannelPreview} from '@app/features/discovery/commands/DiscoveryCommands'; +import * as DiscoveryCommands from '@app/features/discovery/commands/DiscoveryCommands'; +import {failureCode} from '@app/features/platform/utils/ResponseInspection'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {makeAutoObservable, observable, runInAction} from 'mobx'; + +type DiscoveryChannelPreviewEntry = + | {status: 'loading'} + | {status: 'ready'; preview: DiscoveryChannelPreview} + | {status: 'unavailable'}; + +function entryKey(guildId: string, channelId: string): string { + return `${guildId}:${channelId}`; +} + +class DiscoveryChannelPreviews { + private entries = observable.map(); + private discoveryDisabled = false; + + constructor() { + makeAutoObservable(this, {}, {autoBind: true}); + } + + get(guildId: string, channelId: string): DiscoveryChannelPreviewEntry | undefined { + return this.entries.get(entryKey(guildId, channelId)); + } + + request(guildId: string, channelId: string): void { + const key = entryKey(guildId, channelId); + if (this.discoveryDisabled || this.entries.has(key)) return; + this.entries.set(key, {status: 'loading'}); + DiscoveryCommands.getChannelPreview(guildId, channelId).then( + (preview) => { + runInAction(() => this.entries.set(key, {status: 'ready', preview})); + }, + (error: unknown) => { + const code = failureCode(error); + runInAction(() => { + if (code === APIErrorCodes.DISCOVERY_DISABLED) { + this.discoveryDisabled = true; + this.entries.set(key, {status: 'unavailable'}); + } else if (code === APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) { + this.entries.set(key, {status: 'unavailable'}); + } else { + this.entries.delete(key); + } + }); + }, + ); + } +} + +export default new DiscoveryChannelPreviews(); diff --git a/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx b/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx index 9b6920178..426d33a1a 100644 --- a/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx +++ b/fluxer_app/src/features/messaging/components/markdown/renderers/LinkRenderer.tsx @@ -11,6 +11,8 @@ import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/ChannelUtils'; import DeveloperMode from '@app/features/devtools/state/DeveloperMode'; +import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands'; +import DiscoveryChannelPreviews from '@app/features/discovery/state/DiscoveryChannelPreviews'; import type {Guild} from '@app/features/guild/models/Guild'; import Guilds from '@app/features/guild/state/Guilds'; import {getGuildIconDisplayInitials} from '@app/features/guild/utils/GuildInitialsUtils'; @@ -75,6 +77,7 @@ import type {UserSettingsDeepLinkTarget} from '@app/features/user/components/set import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import * as StringUtils from '@app/lib/strings'; import {ME} from '@fluxer/constants/src/AppConstants'; +import {isProbablyAValidSnowflake} from '@fluxer/snowflake/src/SnowflakeUtils'; import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react'; @@ -82,7 +85,7 @@ import {CaretRightIcon, ChatTeardropIcon, LockIcon} from '@phosphor-icons/react' import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useCallback} from 'react'; +import {useCallback, useEffect, useState} from 'react'; const LINK_CONTENT_OPTION_OVERRIDES: Partial = {disableEmojiInteractions: true}; @@ -201,7 +204,7 @@ type CompactGuildMentionIconStyle = React.CSSProperties & { '--jump-link-guild-icon-image'?: string; }; -function CompactGuildMentionIcon({guild}: {guild: Guild}) { +function CompactGuildMentionIcon({guild}: {guild: Pick}) { const iconUrl = guild.icon ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : ''; const style: CompactGuildMentionIconStyle | undefined = iconUrl ? {'--jump-link-guild-icon-image': `url(${iconUrl})`} @@ -514,6 +517,143 @@ function InaccessibleJumpLinkMention({url, i18n, interactive = true}: Inaccessib ); } +interface DiscoverableJumpLinkMentionProps { + guildId: string; + channelId: string; + messageId?: string; + url: string; + i18n: I18n; + interactive?: boolean; +} + +const DiscoverableJumpLinkMention = observer(function DiscoverableJumpLinkMention({ + guildId, + channelId, + messageId, + url, + i18n, + interactive = true, +}: DiscoverableJumpLinkMentionProps) { + useLingui(); + const [joining, setJoining] = useState(false); + useEffect(() => { + DiscoveryChannelPreviews.request(guildId, channelId); + }, [guildId, channelId]); + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!interactive) return; + event.preventDefault(); + event.stopPropagation(); + if (joining) return; + setJoining(true); + void joinDiscoveryGuild(guildId, {channelId, messageId}).finally(() => setJoining(false)); + }, + [channelId, guildId, interactive, joining, messageId], + ); + const handleAuxClick = useCallback( + (event: React.MouseEvent) => { + if (!interactive) return; + if (event.button !== 1) return; + event.preventDefault(); + event.stopPropagation(); + void openExternalUrl(url); + }, + [interactive, url], + ); + const handleContextMenu = useCallback( + (event: React.MouseEvent) => { + if (!interactive) return; + ContextMenuCommands.openFromEvent(event, ({onClose}) => ( + + )); + }, + [interactive, i18n, url], + ); + const entry = DiscoveryChannelPreviews.get(guildId, channelId); + if (entry?.status !== 'ready') { + return ( + + ); + } + const {guild, channel} = entry.preview; + const ariaLabel = messageId + ? i18n._(JUMP_TO_THE_MESSAGE_IN_DESCRIPTOR, {labelText: guild.name}) + : i18n._(JUMP_TO_DESCRIPTOR, {labelText: guild.name}); + const Component = interactive ? 'button' : 'span'; + return ( + + + + + {guild.name} + + + + {messageId ? ( + + ) : ( + + {getIcon({type: channel.type}, {className: jumpLinkStyles.icon})} + + {channel.name ?? channel.id} + + + )} + + ); +}); + interface SettingsJumpLinkMentionProps { target: UserSettingsDeepLinkTarget; url: string; @@ -740,6 +880,14 @@ export const LinkRenderer = observer(function LinkRenderer({ const jumpTarget = messageJumpTarget ?? channelJumpTarget; const jumpChannel = jumpTarget ? (Channels.getChannel(jumpTarget.channelId) ?? null) : null; const jumpGuild = jumpChannel?.guildId ? (Guilds.getGuild(jumpChannel.guildId) ?? null) : null; + const discoverableGuildId = + jumpTarget && + !jumpChannel && + jumpTarget.scope !== ME && + isProbablyAValidSnowflake(jumpTarget.scope) && + !Guilds.getGuild(jumpTarget.scope) + ? jumpTarget.scope + : null; const settingsTarget = isAppProtocolUrl(url) ? parseUserSettingsDeepLink(url) : null; const appPage = settingsTarget ? null : parseAppPageLink(url); const isInlineReplyContext = isRestrictedInlineContext(options.context); @@ -810,11 +958,22 @@ export const LinkRenderer = observer(function LinkRenderer({ ); } if (jumpTarget && !jumpChannel && !text) { - const mention = ( + const interactive = !isInlineReplyContext && !shouldDisableInteractions; + const mention = discoverableGuildId ? ( + + ) : ( ); diff --git a/packages/schema/src/domains/common/CommonParamSchemas.ts b/packages/schema/src/domains/common/CommonParamSchemas.ts index 0322cb1c1..afdb1f64b 100644 --- a/packages/schema/src/domains/common/CommonParamSchemas.ts +++ b/packages/schema/src/domains/common/CommonParamSchemas.ts @@ -50,6 +50,10 @@ export const GuildIdUserIdParam = GuildIdParam.extend(UserIdParam.shape); export type GuildIdUserIdParam = z.infer; +export const GuildIdChannelIdParam = GuildIdParam.extend(ChannelIdParam.shape); + +export type GuildIdChannelIdParam = z.infer; + export const GuildIdRoleIdParam = GuildIdParam.extend({ role_id: SnowflakeType.describe('The ID of the role'), }); diff --git a/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts b/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts index c6e0eea76..fabfeec19 100644 --- a/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts +++ b/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts @@ -100,6 +100,25 @@ export const DiscoveryGuildListResponse = z.object({ export type DiscoveryGuildListResponse = z.infer; +export const DiscoveryChannelPreviewResponse = z.object({ + guild: z + .object({ + id: SnowflakeStringType.describe('Guild ID'), + name: z.string().describe('Guild name'), + icon: z.string().nullable().describe('Guild icon hash'), + }) + .describe('The discoverable guild the channel belongs to'), + channel: z + .object({ + id: SnowflakeStringType.describe('Channel ID'), + name: z.string().nullable().describe('Channel name'), + type: z.number().describe('Channel type'), + }) + .describe('A channel that new members can view'), +}); + +export type DiscoveryChannelPreviewResponse = z.infer; + export const DiscoveryApplicationResponse = z.object({ guild_id: SnowflakeStringType.describe('Guild ID'), guild_nsfw_level: NSFWLevelSchema.nullable().optional().describe('NSFW level of the guild'),