mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
feat(discovery): resolve message links into discoverable guilds (#3159)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {Config} from '@app/api/Config';
|
||||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
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 {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
|
||||||
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
||||||
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
|
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 {
|
import {
|
||||||
DiscoveryApplicationPatchRequest,
|
DiscoveryApplicationPatchRequest,
|
||||||
DiscoveryApplicationRequest,
|
DiscoveryApplicationRequest,
|
||||||
DiscoveryApplicationResponse,
|
DiscoveryApplicationResponse,
|
||||||
DiscoveryCategoryListResponse,
|
DiscoveryCategoryListResponse,
|
||||||
|
DiscoveryChannelPreviewResponse,
|
||||||
DiscoveryGuildListResponse,
|
DiscoveryGuildListResponse,
|
||||||
DiscoverySearchQuery,
|
DiscoverySearchQuery,
|
||||||
DiscoveryStatusResponse,
|
DiscoveryStatusResponse,
|
||||||
@@ -100,6 +101,31 @@ export function GuildDiscoveryController(app: HonoApp) {
|
|||||||
return ctx.json(categories);
|
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(
|
app.post(
|
||||||
'/discovery/guilds/:guild_id/join',
|
'/discovery/guilds/:guild_id/join',
|
||||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {Config} from '@app/api/Config';
|
||||||
|
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
|
||||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||||
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
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 type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||||
import {Logger} from '@app/api/Logger';
|
import {Logger} from '@app/api/Logger';
|
||||||
import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
|
import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
|
||||||
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
import {
|
import {
|
||||||
DISCOVERY_DEFAULT_LANGUAGE,
|
DISCOVERY_DEFAULT_LANGUAGE,
|
||||||
DISCOVERY_MAX_TAGS,
|
DISCOVERY_MAX_TAGS,
|
||||||
@@ -83,6 +85,8 @@ export abstract class IGuildDiscoveryService {
|
|||||||
|
|
||||||
abstract listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>>;
|
abstract listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>>;
|
||||||
|
|
||||||
|
abstract getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview>;
|
||||||
|
|
||||||
abstract searchDiscoverable(params: {
|
abstract searchDiscoverable(params: {
|
||||||
query?: string;
|
query?: string;
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
@@ -118,7 +122,13 @@ interface DiscoveryGuildResult {
|
|||||||
verification_level: number;
|
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 DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
|
||||||
|
const PUBLIC_CHANNEL_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
|
||||||
|
|
||||||
function toDiscoveryCategoryCounts(
|
function toDiscoveryCategoryCounts(
|
||||||
counts: Readonly<Record<string, number>> | undefined,
|
counts: Readonly<Record<string, number>> | undefined,
|
||||||
@@ -143,6 +153,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
|||||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||||
private readonly gatewayService: IGatewayService,
|
private readonly gatewayService: IGatewayService,
|
||||||
private readonly guildSearchService: IGuildSearchService | null,
|
private readonly guildSearchService: IGuildSearchService | null,
|
||||||
|
private readonly channelDataRepository: IChannelDataRepository,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -382,6 +393,38 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
|||||||
return this.discoveryRepository.listFullByStatus(params.status);
|
return this.discoveryRepository.listFullByStatus(params.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview> {
|
||||||
|
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: {
|
async searchDiscoverable(params: {
|
||||||
query?: string;
|
query?: string;
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||||
import type {GuildID} from '@app/api/BrandedTypes';
|
import type {GuildID} from '@app/api/BrandedTypes';
|
||||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
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 {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry';
|
||||||
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
|
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
|
||||||
import {banUser} from '@app/api/moderation/tests/ModerationTestUtils';
|
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 syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex';
|
||||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||||
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||||
import type {
|
import type {
|
||||||
DiscoveryApplicationResponse,
|
DiscoveryApplicationResponse,
|
||||||
DiscoveryCategoryResponse,
|
DiscoveryCategoryResponse,
|
||||||
|
DiscoveryChannelPreviewResponse,
|
||||||
DiscoveryGuildListResponse,
|
DiscoveryGuildListResponse,
|
||||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||||
@@ -507,4 +510,65 @@ describe('Discovery Search and Join', () => {
|
|||||||
.execute();
|
.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<DiscoveryChannelPreviewResponse>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ export const getGuildDiscoveryService = singleton(
|
|||||||
getGuildRepository(),
|
getGuildRepository(),
|
||||||
getGatewayService(),
|
getGatewayService(),
|
||||||
getGuildSearchService(),
|
getGuildSearchService(),
|
||||||
|
getChannelRepository().channelData,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService()));
|
export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService()));
|
||||||
|
|||||||
@@ -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": {
|
"/discovery/guilds/{guild_id}/join": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "join_discovery_guild",
|
"operationId": "join_discovery_guild",
|
||||||
@@ -28579,6 +28650,35 @@
|
|||||||
"required": ["url"],
|
"required": ["url"],
|
||||||
"additionalProperties": false
|
"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": {
|
"DiscoveryGuildListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export const DiscoveryRateLimitConfigs = {
|
|||||||
bucket: 'discovery:join',
|
bucket: 'discovery:join',
|
||||||
config: {limit: 10, windowMs: ms('1 minute')},
|
config: {limit: 10, windowMs: ms('1 minute')},
|
||||||
} as RouteRateLimitConfig,
|
} as RouteRateLimitConfig,
|
||||||
|
DISCOVERY_CHANNEL_PREVIEW: {
|
||||||
|
bucket: 'discovery:channel_preview',
|
||||||
|
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||||
|
} as RouteRateLimitConfig,
|
||||||
DISCOVERY_APPLY: {
|
DISCOVERY_APPLY: {
|
||||||
bucket: 'discovery:apply::guild_id',
|
bucket: 'discovery:apply::guild_id',
|
||||||
config: {limit: 5, windowMs: ms('1 minute')},
|
config: {limit: 5, windowMs: ms('1 minute')},
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ export const Endpoints = {
|
|||||||
DISCOVERY_GUILDS: '/discovery/guilds',
|
DISCOVERY_GUILDS: '/discovery/guilds',
|
||||||
DISCOVERY_CATEGORIES: '/discovery/categories',
|
DISCOVERY_CATEGORIES: '/discovery/categories',
|
||||||
DISCOVERY_JOIN: (guildId: string) => `/discovery/guilds/${guildId}/join`,
|
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`,
|
GUILD_DISCOVERY: (guildId: string) => `/guilds/${guildId}/discovery`,
|
||||||
CONNECTIONS: '/users/@me/connections',
|
CONNECTIONS: '/users/@me/connections',
|
||||||
CONNECTIONS_VERIFY_AND_CREATE: '/users/@me/connections/verify',
|
CONNECTIONS_VERIFY_AND_CREATE: '/users/@me/connections/verify',
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface DiscoveryGuild {
|
|||||||
|
|
||||||
type DiscoveryGuildPayload = Omit<DiscoveryGuild, 'banner'> & {banner?: string | null};
|
type DiscoveryGuildPayload = Omit<DiscoveryGuild, 'banner'> & {banner?: string | null};
|
||||||
|
|
||||||
|
export interface DiscoveryChannelPreview {
|
||||||
|
guild: {id: string; name: string; icon: string | null};
|
||||||
|
channel: {id: string; name: string | null; type: number};
|
||||||
|
}
|
||||||
|
|
||||||
interface DiscoveryCategoryCountPayload {
|
interface DiscoveryCategoryCountPayload {
|
||||||
category_type: number;
|
category_type: number;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -115,6 +120,11 @@ export async function getCategories(): Promise<Array<DiscoveryCategory>> {
|
|||||||
return requestDiscoveryCategories();
|
return requestDiscoveryCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getChannelPreview(guildId: string, channelId: string): Promise<DiscoveryChannelPreview> {
|
||||||
|
const response = await http.get<DiscoveryChannelPreview>(Endpoints.DISCOVERY_CHANNEL_PREVIEW(guildId, channelId));
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function joinGuild(guildId: string): Promise<void> {
|
export async function joinGuild(guildId: string): Promise<void> {
|
||||||
await http.post(Endpoints.DISCOVERY_JOIN(guildId));
|
await http.post(Endpoints.DISCOVERY_JOIN(guildId));
|
||||||
logger.info('Joined guild via discovery', {guildId});
|
logger.info('Joined guild via discovery', {guildId});
|
||||||
|
|||||||
@@ -112,10 +112,17 @@ function showJoinGuildErrorModal(error: unknown): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinDiscoveryGuild(guildId: string): Promise<boolean> {
|
export async function joinDiscoveryGuild(
|
||||||
|
guildId: string,
|
||||||
|
target?: {channelId: string; messageId?: string},
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await DiscoveryCommands.joinGuild(guildId);
|
await DiscoveryCommands.joinGuild(guildId);
|
||||||
|
if (target) {
|
||||||
|
NavigationCommands.selectChannel(guildId, target.channelId, target.messageId);
|
||||||
|
} else {
|
||||||
NavigationCommands.selectGuild(guildId);
|
NavigationCommands.selectGuild(guildId);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showJoinGuildErrorModal(error);
|
showJoinGuildErrorModal(error);
|
||||||
|
|||||||
@@ -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<string, DiscoveryChannelPreviewEntry>();
|
||||||
|
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();
|
||||||
@@ -11,6 +11,8 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
|||||||
import Channels from '@app/features/channel/state/Channels';
|
import Channels from '@app/features/channel/state/Channels';
|
||||||
import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/ChannelUtils';
|
import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/ChannelUtils';
|
||||||
import DeveloperMode from '@app/features/devtools/state/DeveloperMode';
|
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 type {Guild} from '@app/features/guild/models/Guild';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
import Guilds from '@app/features/guild/state/Guilds';
|
||||||
import {getGuildIconDisplayInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
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 AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||||
import * as StringUtils from '@app/lib/strings';
|
import * as StringUtils from '@app/lib/strings';
|
||||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||||
|
import {isProbablyAValidSnowflake} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||||
import type {I18n} from '@lingui/core';
|
import type {I18n} from '@lingui/core';
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
import {useLingui} from '@lingui/react';
|
import {useLingui} from '@lingui/react';
|
||||||
@@ -82,7 +85,7 @@ import {CaretRightIcon, ChatTeardropIcon, LockIcon} from '@phosphor-icons/react'
|
|||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {useCallback} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
|
|
||||||
const LINK_CONTENT_OPTION_OVERRIDES: Partial<MarkdownRenderOptions> = {disableEmojiInteractions: true};
|
const LINK_CONTENT_OPTION_OVERRIDES: Partial<MarkdownRenderOptions> = {disableEmojiInteractions: true};
|
||||||
|
|
||||||
@@ -201,7 +204,7 @@ type CompactGuildMentionIconStyle = React.CSSProperties & {
|
|||||||
'--jump-link-guild-icon-image'?: string;
|
'--jump-link-guild-icon-image'?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CompactGuildMentionIcon({guild}: {guild: Guild}) {
|
function CompactGuildMentionIcon({guild}: {guild: Pick<Guild, 'id' | 'name' | 'icon'>}) {
|
||||||
const iconUrl = guild.icon ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : '';
|
const iconUrl = guild.icon ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : '';
|
||||||
const style: CompactGuildMentionIconStyle | undefined = iconUrl
|
const style: CompactGuildMentionIconStyle | undefined = iconUrl
|
||||||
? {'--jump-link-guild-icon-image': `url(${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<HTMLButtonElement | HTMLSpanElement>) => {
|
||||||
|
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<HTMLButtonElement | HTMLSpanElement>) => {
|
||||||
|
if (!interactive) return;
|
||||||
|
if (event.button !== 1) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void openExternalUrl(url);
|
||||||
|
},
|
||||||
|
[interactive, url],
|
||||||
|
);
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(event: React.MouseEvent<HTMLButtonElement | HTMLSpanElement>) => {
|
||||||
|
if (!interactive) return;
|
||||||
|
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
|
||||||
|
<JumpLinkContextMenu
|
||||||
|
url={url}
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={onClose}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.context-menu"
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
},
|
||||||
|
[interactive, i18n, url],
|
||||||
|
);
|
||||||
|
const entry = DiscoveryChannelPreviews.get(guildId, channelId);
|
||||||
|
if (entry?.status !== 'ready') {
|
||||||
|
return (
|
||||||
|
<InaccessibleJumpLinkMention
|
||||||
|
url={url}
|
||||||
|
i18n={i18n}
|
||||||
|
interactive={interactive}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.inaccessible"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<Component
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.component.click"
|
||||||
|
{...(interactive ? {type: 'button'} : {})}
|
||||||
|
className={clsx(markupStyles.mention, interactive && markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onAuxClick={handleAuxClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-busy={joining || undefined}
|
||||||
|
{...(interactive
|
||||||
|
? {'aria-roledescription': i18n._(messageId ? MESSAGE_LINK_DESCRIPTOR : CHANNEL_LINK_DESCRIPTOR)}
|
||||||
|
: {})}
|
||||||
|
tabIndex={interactive ? 0 : -1}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={jumpLinkStyles.part}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.part--guild"
|
||||||
|
>
|
||||||
|
<CompactGuildMentionIcon
|
||||||
|
guild={guild}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.guild-icon"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={jumpLinkStyles.name}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.guild-name"
|
||||||
|
>
|
||||||
|
{guild.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={jumpLinkStyles.divider}
|
||||||
|
aria-hidden="true"
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.divider"
|
||||||
|
>
|
||||||
|
<CaretRightIcon
|
||||||
|
weight="bold"
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.caret-right-icon"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{messageId ? (
|
||||||
|
<ChatTeardropIcon
|
||||||
|
className={jumpLinkStyles.icon}
|
||||||
|
weight="fill"
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.chat-teardrop-icon"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={jumpLinkStyles.part}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.part--channel"
|
||||||
|
>
|
||||||
|
{getIcon({type: channel.type}, {className: jumpLinkStyles.icon})}
|
||||||
|
<span
|
||||||
|
className={jumpLinkStyles.name}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention.channel-name"
|
||||||
|
>
|
||||||
|
{channel.name ?? channel.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface SettingsJumpLinkMentionProps {
|
interface SettingsJumpLinkMentionProps {
|
||||||
target: UserSettingsDeepLinkTarget;
|
target: UserSettingsDeepLinkTarget;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -740,6 +880,14 @@ export const LinkRenderer = observer(function LinkRenderer({
|
|||||||
const jumpTarget = messageJumpTarget ?? channelJumpTarget;
|
const jumpTarget = messageJumpTarget ?? channelJumpTarget;
|
||||||
const jumpChannel = jumpTarget ? (Channels.getChannel(jumpTarget.channelId) ?? null) : null;
|
const jumpChannel = jumpTarget ? (Channels.getChannel(jumpTarget.channelId) ?? null) : null;
|
||||||
const jumpGuild = jumpChannel?.guildId ? (Guilds.getGuild(jumpChannel.guildId) ?? 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 settingsTarget = isAppProtocolUrl(url) ? parseUserSettingsDeepLink(url) : null;
|
||||||
const appPage = settingsTarget ? null : parseAppPageLink(url);
|
const appPage = settingsTarget ? null : parseAppPageLink(url);
|
||||||
const isInlineReplyContext = isRestrictedInlineContext(options.context);
|
const isInlineReplyContext = isRestrictedInlineContext(options.context);
|
||||||
@@ -810,11 +958,22 @@ export const LinkRenderer = observer(function LinkRenderer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (jumpTarget && !jumpChannel && !text) {
|
if (jumpTarget && !jumpChannel && !text) {
|
||||||
const mention = (
|
const interactive = !isInlineReplyContext && !shouldDisableInteractions;
|
||||||
|
const mention = discoverableGuildId ? (
|
||||||
|
<DiscoverableJumpLinkMention
|
||||||
|
guildId={discoverableGuildId}
|
||||||
|
channelId={jumpTarget.channelId}
|
||||||
|
messageId={messageJumpTarget?.messageId}
|
||||||
|
url={url}
|
||||||
|
i18n={i18n}
|
||||||
|
interactive={interactive}
|
||||||
|
data-flx="messaging.markdown.renderers.link-renderer.discoverable-jump-link-mention"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<InaccessibleJumpLinkMention
|
<InaccessibleJumpLinkMention
|
||||||
url={url}
|
url={url}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
interactive={!isInlineReplyContext && !shouldDisableInteractions}
|
interactive={interactive}
|
||||||
data-flx="messaging.markdown.renderers.link-renderer.inaccessible-jump-link-mention"
|
data-flx="messaging.markdown.renderers.link-renderer.inaccessible-jump-link-mention"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export const GuildIdUserIdParam = GuildIdParam.extend(UserIdParam.shape);
|
|||||||
|
|
||||||
export type GuildIdUserIdParam = z.infer<typeof GuildIdUserIdParam>;
|
export type GuildIdUserIdParam = z.infer<typeof GuildIdUserIdParam>;
|
||||||
|
|
||||||
|
export const GuildIdChannelIdParam = GuildIdParam.extend(ChannelIdParam.shape);
|
||||||
|
|
||||||
|
export type GuildIdChannelIdParam = z.infer<typeof GuildIdChannelIdParam>;
|
||||||
|
|
||||||
export const GuildIdRoleIdParam = GuildIdParam.extend({
|
export const GuildIdRoleIdParam = GuildIdParam.extend({
|
||||||
role_id: SnowflakeType.describe('The ID of the role'),
|
role_id: SnowflakeType.describe('The ID of the role'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,6 +100,25 @@ export const DiscoveryGuildListResponse = z.object({
|
|||||||
|
|
||||||
export type DiscoveryGuildListResponse = z.infer<typeof DiscoveryGuildListResponse>;
|
export type DiscoveryGuildListResponse = z.infer<typeof DiscoveryGuildListResponse>;
|
||||||
|
|
||||||
|
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<typeof DiscoveryChannelPreviewResponse>;
|
||||||
|
|
||||||
export const DiscoveryApplicationResponse = z.object({
|
export const DiscoveryApplicationResponse = z.object({
|
||||||
guild_id: SnowflakeStringType.describe('Guild ID'),
|
guild_id: SnowflakeStringType.describe('Guild ID'),
|
||||||
guild_nsfw_level: NSFWLevelSchema.nullable().optional().describe('NSFW level of the guild'),
|
guild_nsfw_level: NSFWLevelSchema.nullable().optional().describe('NSFW level of the guild'),
|
||||||
|
|||||||
Reference in New Issue
Block a user