diff --git a/locales/en-US.yml b/locales/en-US.yml index 8c437712f5e4..ad9876e22a27 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -376,6 +376,8 @@ pinnedPages: "Pinned Pages" pinnedPagesDescription: "Enter the paths of the Pages you want to pin to the top page of this instance, separated by line breaks." pinnedClipId: "ID of the clip to pin" pinnedNotes: "Pinned notes" +featuredGameChannels: "Featured Misskey Games Channels" +featuredGameChannelsDescription: "Enter the channels you want to pin to Misskey Games, separated by line breaks." hcaptcha: "hCaptcha" enableHcaptcha: "Enable hCaptcha" hcaptchaSiteKey: "Site key" diff --git a/locales/index.d.ts b/locales/index.d.ts index f30481828db7..be8b5e802a45 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1536,6 +1536,14 @@ export interface Locale extends ILocale { * ピン留めされたノート */ "pinnedNotes": string; + /** + * Misskey Gamesのピン留めチャンネル + */ + "featuredGameChannels": string; + /** + * Misskey Gamesにピン留めしたいチャンネルを改行で区切って記述します。 + */ + "featuredGameChannelsDescription": string; /** * hCaptcha */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c7e1bc77c02b..b4fd8616a528 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -380,6 +380,8 @@ pinnedPages: "ピン留めページ" pinnedPagesDescription: "サーバーのトップページにピン留めしたいページのパスを改行で区切って記述します。" pinnedClipId: "ピン留めするクリップのID" pinnedNotes: "ピン留めされたノート" +featuredGameChannels: "Misskey Gamesのピン留めチャンネル" +featuredGameChannelsDescription: "Misskey Gamesにピン留めしたいチャンネルを改行で区切って記述します。" hcaptcha: "hCaptcha" enableHcaptcha: "hCaptchaを有効にする" hcaptchaSiteKey: "サイトキー" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index dccb7af3053c..c3802bf0a0fc 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -379,6 +379,8 @@ pinnedPages: "고정한 페이지" pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다." pinnedClipId: "고정할 클립의 ID" pinnedNotes: "고정된 노트" +featuredGameChannels: "Misskey Games에 고정할 채널" +featuredGameChannelsDescription: "Misskey Games에 고정할 채널을 한 줄에 하나씩 적습니다." hcaptcha: "hCaptcha" enableHcaptcha: "hCaptcha 활성화" hcaptchaSiteKey: "사이트 키" diff --git a/packages/backend/migration/1706081039979-featured-games-channel.js b/packages/backend/migration/1706081039979-featured-games-channel.js new file mode 100644 index 000000000000..dbde1d418192 --- /dev/null +++ b/packages/backend/migration/1706081039979-featured-games-channel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FeaturedGamesChannel1706081039979 { + name = 'FeaturedGamesChannel1706081039979' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "featuredGameChannels" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "featuredGameChannels"`); + } +} diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index fd3d53640aa3..4c52bb6cf898 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -97,5 +97,14 @@ export class ChannelEntityService { } : {}), }; } -} + public async packMany( + channels: (MiChannel['id'] | MiChannel)[], + me: { id: MiUser['id'] } | null | undefined, + detailed?: boolean, + ): Promise[]> { + return (await Promise.allSettled(channels.map(x => this.pack(x, me, detailed)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 252e51aadc63..c709fc6a4948 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -591,4 +591,9 @@ export class MiMeta { length: 3072, array: true, default: '{}', }) public urlPreviewDenyList: string[]; + + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public featuredGameChannels: string[]; } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ca24d8a082fc..1951893a7744 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -106,6 +106,7 @@ import * as ep___blocking_delete from './endpoints/blocking/delete.js'; import * as ep___blocking_list from './endpoints/blocking/list.js'; import * as ep___channels_create from './endpoints/channels/create.js'; import * as ep___channels_featured from './endpoints/channels/featured.js'; +import * as ep___channels_featured_games from './endpoints/channels/featured-games.js'; import * as ep___channels_follow from './endpoints/channels/follow.js'; import * as ep___channels_followed from './endpoints/channels/followed.js'; import * as ep___channels_owned from './endpoints/channels/owned.js'; @@ -482,6 +483,7 @@ const $blocking_delete: Provider = { provide: 'ep:blocking/delete', useClass: ep const $blocking_list: Provider = { provide: 'ep:blocking/list', useClass: ep___blocking_list.default }; const $channels_create: Provider = { provide: 'ep:channels/create', useClass: ep___channels_create.default }; const $channels_featured: Provider = { provide: 'ep:channels/featured', useClass: ep___channels_featured.default }; +const $channels_featured_games: Provider = { provide: 'ep:channels/featured-games', useClass: ep___channels_featured_games.default }; const $channels_follow: Provider = { provide: 'ep:channels/follow', useClass: ep___channels_follow.default }; const $channels_followed: Provider = { provide: 'ep:channels/followed', useClass: ep___channels_followed.default }; const $channels_owned: Provider = { provide: 'ep:channels/owned', useClass: ep___channels_owned.default }; @@ -862,6 +864,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $blocking_list, $channels_create, $channels_featured, + $channels_featured_games, $channels_follow, $channels_followed, $channels_owned, @@ -1236,6 +1239,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $blocking_list, $channels_create, $channels_featured, + $channels_featured_games, $channels_follow, $channels_followed, $channels_owned, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 342921062de9..432c824ee1ac 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -107,6 +107,7 @@ import * as ep___blocking_delete from './endpoints/blocking/delete.js'; import * as ep___blocking_list from './endpoints/blocking/list.js'; import * as ep___channels_create from './endpoints/channels/create.js'; import * as ep___channels_featured from './endpoints/channels/featured.js'; +import * as ep___channels_featured_games from './endpoints/channels/featured-games.js'; import * as ep___channels_follow from './endpoints/channels/follow.js'; import * as ep___channels_followed from './endpoints/channels/followed.js'; import * as ep___channels_owned from './endpoints/channels/owned.js'; @@ -481,6 +482,7 @@ const eps = [ ['blocking/list', ep___blocking_list], ['channels/create', ep___channels_create], ['channels/featured', ep___channels_featured], + ['channels/featured-games', ep___channels_featured_games], ['channels/follow', ep___channels_follow], ['channels/followed', ep___channels_followed], ['channels/owned', ep___channels_owned], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 33c5954d180b..3d5d3a70bd70 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -382,6 +382,13 @@ export const meta = { optional: false, nullable: false, }, }, + featuredGameChannels: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, backgroundImageUrl: { type: 'string', optional: false, nullable: true, @@ -589,6 +596,7 @@ export default class extends Endpoint { // eslint- perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, notesPerOneAd: instance.notesPerOneAd, urlPreviewDenyList: instance.urlPreviewDenyList, + featuredGameChannels: instance.featuredGameChannels, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 1373b5959e32..e0ad1f10d6bb 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -150,9 +150,16 @@ export const paramDef = { type: 'string', }, }, - urlPreviewDenyList: { type: 'array', nullable: true, items: { - type: 'string', - } }, + urlPreviewDenyList: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, + featuredGameChannels: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, }, required: [], } as const; @@ -208,6 +215,10 @@ export default class extends Endpoint { // eslint- set.urlPreviewDenyList = ps.urlPreviewDenyList.filter(Boolean); } + if (Array.isArray(ps.featuredGameChannels)) { + set.featuredGameChannels = ps.featuredGameChannels.filter(Boolean); + } + if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } diff --git a/packages/backend/src/server/api/endpoints/channels/featured-games.ts b/packages/backend/src/server/api/endpoints/channels/featured-games.ts new file mode 100644 index 000000000000..9b2cbdebd35e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/featured-games.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { ChannelsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private metaService: MetaService, + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const meta = await this.metaService.fetch(); + + if (meta.featuredGameChannels.length === 0) return []; + + const channels = await this.channelsRepository.findBy({ + id: In(meta.featuredGameChannels) + }); + + return await this.channelEntityService.packMany(channels, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts index 412ea1bb1636..bafa12cd2c3a 100644 --- a/packages/backend/src/server/api/endpoints/channels/featured.ts +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { // eslint- const channels = await query.limit(10).getMany(); - return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + return await this.channelEntityService.packMany(channels, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index 6514f1ea3cd6..f866b1096f58 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -48,14 +48,15 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) - .andWhere({ followerId: me.id }); + const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder('followings'), ps.sinceId, ps.untilId) + .andWhere('followings.followerId = :meId', { meId: me.id }) + .innerJoinAndSelect('followings.followee', 'channel'); const followings = await query .limit(ps.limit) .getMany(); - return await Promise.all(followings.map(x => this.channelEntityService.pack(x.followeeId, me))); + return await this.channelEntityService.packMany(followings.map(x => x.followee!), me); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts index 057a438ac912..d743616be392 100644 --- a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts +++ b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts @@ -45,12 +45,12 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const query = this.channelFavoritesRepository.createQueryBuilder('favorite') .andWhere('favorite.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('favorite.channel', 'channel'); + .innerJoinAndSelect('favorite.channel', 'channel'); const favorites = await query .getMany(); - return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); + return await this.channelEntityService.packMany(favorites.map(x => x.channel!), me); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index b1dd6935371f..36eba59aaad8 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -56,7 +56,7 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); - return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + return await this.channelEntityService.packMany(channels, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts index 9c78a948443f..3c6fe4f0471d 100644 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -69,7 +69,7 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); - return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + return await this.channelEntityService.packMany(channels, me); }); } } diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 45375764b7bc..f3df3f40ff30 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -45,6 +45,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + @@ -171,6 +176,7 @@ const maintainerName = ref(null); const maintainerEmail = ref(null); const impressumUrl = ref(null); const pinnedUsers = ref(''); +const featuredGameChannels = ref(''); const cacheRemoteFiles = ref(false); const cacheRemoteSensitiveFiles = ref(false); const enableServiceWorker = ref(false); @@ -193,6 +199,7 @@ async function init(): Promise { maintainerEmail.value = meta.maintainerEmail; impressumUrl.value = meta.impressumUrl; pinnedUsers.value = meta.pinnedUsers.join('\n'); + featuredGameChannels.value = meta.featuredGameChannels.join('\n'); cacheRemoteFiles.value = meta.cacheRemoteFiles; cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles; enableServiceWorker.value = meta.enableServiceWorker; @@ -216,6 +223,7 @@ async function save(): void { maintainerEmail: maintainerEmail.value, impressumUrl: impressumUrl.value, pinnedUsers: pinnedUsers.value.split('\n'), + featuredGameChannels: featuredGameChannels.value.split('\n'), cacheRemoteFiles: cacheRemoteFiles.value, cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value, enableServiceWorker: enableServiceWorker.value, diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index 45a135a459a5..856608f9032d 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -19,16 +19,25 @@ SPDX-License-Identifier: AGPL-3.0-only + + + diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index cebc8440d36a..00a6eb25d777 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -722,6 +722,9 @@ type ChannelsCreateResponse = operations['channels/create']['responses']['200'][ // @public (undocumented) type ChannelsFavoriteRequest = operations['channels/favorite']['requestBody']['content']['application/json']; +// @public (undocumented) +type ChannelsFeaturedGamesResponse = operations['channels/featured-games']['responses']['200']['content']['application/json']; + // @public (undocumented) type ChannelsFeaturedResponse = operations['channels/featured']['responses']['200']['content']['application/json']; @@ -1246,6 +1249,7 @@ declare namespace entities { ChannelsCreateRequest, ChannelsCreateResponse, ChannelsFeaturedResponse, + ChannelsFeaturedGamesResponse, ChannelsFollowRequest, ChannelsFollowedRequest, ChannelsFollowedResponse, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 9473abfa425e..d20656dc6b22 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1109,6 +1109,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 517b25504622..d7b419bddad3 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -145,6 +145,7 @@ import type { ChannelsCreateRequest, ChannelsCreateResponse, ChannelsFeaturedResponse, + ChannelsFeaturedGamesResponse, ChannelsFollowRequest, ChannelsFollowedRequest, ChannelsFollowedResponse, @@ -666,6 +667,7 @@ export type Endpoints = { 'blocking/list': { req: BlockingListRequest; res: BlockingListResponse }; 'channels/create': { req: ChannelsCreateRequest; res: ChannelsCreateResponse }; 'channels/featured': { req: EmptyRequest; res: ChannelsFeaturedResponse }; + 'channels/featured-games': { req: EmptyRequest; res: ChannelsFeaturedGamesResponse }; 'channels/follow': { req: ChannelsFollowRequest; res: EmptyResponse }; 'channels/followed': { req: ChannelsFollowedRequest; res: ChannelsFollowedResponse }; 'channels/owned': { req: ChannelsOwnedRequest; res: ChannelsOwnedResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 0d78e8c495f1..91c1e7e3ca5f 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -147,6 +147,7 @@ export type BlockingListResponse = operations['blocking/list']['responses']['200 export type ChannelsCreateRequest = operations['channels/create']['requestBody']['content']['application/json']; export type ChannelsCreateResponse = operations['channels/create']['responses']['200']['content']['application/json']; export type ChannelsFeaturedResponse = operations['channels/featured']['responses']['200']['content']['application/json']; +export type ChannelsFeaturedGamesResponse = operations['channels/featured-games']['responses']['200']['content']['application/json']; export type ChannelsFollowRequest = operations['channels/follow']['requestBody']['content']['application/json']; export type ChannelsFollowedRequest = operations['channels/followed']['requestBody']['content']['application/json']; export type ChannelsFollowedResponse = operations['channels/followed']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index f2826a700cfa..53b447483853 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -918,6 +918,15 @@ export type paths = { */ post: operations['channels/featured']; }; + '/channels/featured-games': { + /** + * channels/featured-games + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['channels/featured-games']; + }; '/channels/follow': { /** * channels/follow @@ -4789,6 +4798,7 @@ export type operations = { perUserListTimelineCacheMax: number; notesPerOneAd: number; urlPreviewDenyList?: string[]; + featuredGameChannels: string[]; backgroundImageUrl: string | null; deeplAuthKey: string | null; deeplIsPro: boolean; @@ -8862,6 +8872,7 @@ export type operations = { silencedHosts?: string[] | null; sensitiveMediaHosts?: string[] | null; urlPreviewDenyList?: string[] | null; + featuredGameChannels?: string[] | null; }; }; }; @@ -10677,6 +10688,52 @@ export type operations = { }; }; }; + /** + * channels/featured-games + * @description No description provided. + * + * **Credential required**: *No* + */ + 'channels/featured-games': { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Channel'][]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * channels/follow * @description No description provided.