diff --git a/CHANGELOG.md b/CHANGELOG.md index 760801da543..2adeb135dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Feat: アンテナを公開して他のユーザーが購読・お気に入りできるように (#11132) ### Client - diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 26ef054b9f1..e59917e055b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2540,6 +2540,8 @@ _permissions: "read:invite-codes": "招待コードを取得する" "write:clip-favorite": "クリップのいいねを操作する" "read:clip-favorite": "クリップのいいねを見る" + "write:antenna-favorite": "アンテナのお気に入りを操作する" + "read:antenna-favorite": "アンテナのお気に入りを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" "write:chat": "ダイレクトメッセージを操作する" @@ -2567,6 +2569,12 @@ _antennaSources: userList: "指定したリストのユーザーのノート" userBlacklist: "指定した一人または複数のユーザーを除いた全てのノート" +_antenna: + public: "公開する" + publicDescription: "公開すると、他のユーザーがこのアンテナをタイムラインとして購読・お気に入りできます。あなたから見えるノートのうち、購読ユーザーから見えるものだけが配信されます。" + publicConditionsExposed: "アンテナのキーワードや指定したユーザー等の条件も、他のユーザーから閲覧可能になります。" + favoritedPublicAntennas: "お気に入りの公開アンテナ" + _weekday: sunday: "日曜日" monday: "月曜日" diff --git a/packages/backend/migration/1779105539395-antenna-share.js b/packages/backend/migration/1779105539395-antenna-share.js new file mode 100644 index 00000000000..85c0ea9e54e --- /dev/null +++ b/packages/backend/migration/1779105539395-antenna-share.js @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AntennaShare1779105539395 { + name = 'AntennaShare1779105539395' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "antenna_favorite" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "antennaId" character varying(32) NOT NULL, CONSTRAINT "PK_fefb22a55e21904d2ff5b26eb23" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_ce5de8b5c18d8d9b77132e1be5" ON "antenna_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_82b4a53f04dd7b3fcb217b1436" ON "antenna_favorite" ("userId", "antennaId") `); + await queryRunner.query(`ALTER TABLE "antenna" ADD "isPublic" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "antenna_favorite" ADD CONSTRAINT "FK_ce5de8b5c18d8d9b77132e1be5f" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "antenna_favorite" ADD CONSTRAINT "FK_347623935a4b0999e05a93f5175" FOREIGN KEY ("antennaId") REFERENCES "antenna"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna_favorite" DROP CONSTRAINT "FK_347623935a4b0999e05a93f5175"`); + await queryRunner.query(`ALTER TABLE "antenna_favorite" DROP CONSTRAINT "FK_ce5de8b5c18d8d9b77132e1be5f"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isPublic"`); + await queryRunner.query(`DROP INDEX "public"."IDX_82b4a53f04dd7b3fcb217b1436"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ce5de8b5c18d8d9b77132e1be5"`); + await queryRunner.query(`DROP TABLE "antenna_favorite"`); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index 1f8c8ae3e8e..fcf85463194 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -5,11 +5,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository } from '@/models/_.js'; +import type { AntennaFavoritesRepository, AntennasRepository, MiUser } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { MiAntenna } from '@/models/Antenna.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class AntennaEntityService { @@ -17,6 +19,10 @@ export class AntennaEntityService { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + + private userEntityService: UserEntityService, private idService: IdService, ) { } @@ -24,10 +30,15 @@ export class AntennaEntityService { @bindThis public async pack( src: MiAntenna['id'] | MiAntenna, + me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'>, + }, ): Promise> { + const meId = me ? me.id : null; const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - return { + return await awaitAll({ id: antenna.id, createdAt: this.idService.parse(antenna.id).date.toISOString(), name: antenna.name, @@ -45,6 +56,22 @@ export class AntennaEntityService { isActive: antenna.isActive, hasUnreadNote: false, // TODO notify: false, // 後方互換性のため - }; + isPublic: antenna.isPublic, + userId: antenna.userId, + user: hint?.packedUser ?? this.userEntityService.pack(antenna.user ?? antenna.userId), + favoritedCount: await this.antennaFavoritesRepository.countBy({ antennaId: antenna.id }), + isFavorited: meId ? await this.antennaFavoritesRepository.exists({ where: { antennaId: antenna.id, userId: meId } }) : undefined, + }); + } + + @bindThis + public async packMany( + antennas: MiAntenna[], + me?: { id: MiUser['id'] } | null | undefined, + ) { + const _users = antennas.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(antennas.map(antenna => this.pack(antenna, me, { packedUser: _userMap.get(antenna.userId) }))); } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index b9ca76233c1..429faf0c1ce 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -64,6 +64,7 @@ export const DI = { clipNotesRepository: Symbol('clipNotesRepository'), clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), + antennaFavoritesRepository: Symbol('antennaFavoritesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 3433cf20af1..0fbc8ffe400 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -105,6 +105,11 @@ export class MiAntenna { default: false, }) public excludeNotesInSensitiveChannel: boolean; + + @Column('boolean', { + default: false, + }) + public isPublic: boolean; } // Note for future developers: When you added a new column, // You should update ExportAntennaProcessorService and ImportAntennaProcessorService diff --git a/packages/backend/src/models/AntennaFavorite.ts b/packages/backend/src/models/AntennaFavorite.ts new file mode 100644 index 00000000000..b62bc3de97e --- /dev/null +++ b/packages/backend/src/models/AntennaFavorite.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiAntenna } from './Antenna.js'; + +@Entity('antenna_favorite') +@Index(['userId', 'antennaId'], { unique: true }) +export class MiAntennaFavorite { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column(id()) + public antennaId: MiAntenna['id']; + + @ManyToOne(() => MiAntenna, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public antenna: MiAntenna | null; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index e3db6f88385..30436523340 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -13,6 +13,7 @@ import { MiAnnouncement, MiAnnouncementRead, MiAntenna, + MiAntennaFavorite, MiApp, MiAuthSession, MiAvatarDecoration, @@ -394,6 +395,12 @@ const $antennasRepository: Provider = { inject: [DI.db], }; +const $antennaFavoritesRepository: Provider = { + provide: DI.antennaFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(MiAntennaFavorite).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $promoNotesRepository: Provider = { provide: DI.promoNotesRepository, useFactory: (db: DataSource) => db.getRepository(MiPromoNote).extend(miRepository as MiRepository), @@ -598,6 +605,7 @@ const $reversiGamesRepository: Provider = { $clipNotesRepository, $clipFavoritesRepository, $antennasRepository, + $antennaFavoritesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, @@ -676,6 +684,7 @@ const $reversiGamesRepository: Provider = { $clipNotesRepository, $clipFavoritesRepository, $antennasRepository, + $antennaFavoritesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c4528e3a773..f2003321c24 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -15,6 +15,7 @@ import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; +import { MiAntennaFavorite } from '@/models/AntennaFavorite.js'; import { MiApp } from '@/models/App.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; @@ -104,6 +105,7 @@ export { MiAnnouncement, MiAnnouncementRead, MiAntenna, + MiAntennaFavorite, MiApp, MiAvatarDecoration, MiAuthSession, @@ -184,6 +186,7 @@ export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; export type AnnouncementReadsRepository = Repository & MiRepository; export type AntennasRepository = Repository & MiRepository; +export type AntennaFavoritesRepository = Repository & MiRepository; export type AppsRepository = Repository & MiRepository; export type AvatarDecorationsRepository = Repository & MiRepository; export type AuthSessionsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index eca75630662..c6500ebb85a 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -105,5 +105,28 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + isPublic: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + favoritedCount: { + type: 'number', + optional: false, nullable: false, + }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 3dcd3f09656..33c80419af1 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -18,6 +18,7 @@ import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; +import { MiAntennaFavorite } from '@/models/AntennaFavorite.js'; import { MiApp } from '@/models/App.js'; import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; @@ -227,6 +228,7 @@ export const entities = [ MiClipNote, MiClipFavorite, MiAntenna, + MiAntennaFavorite, MiPromoNote, MiPromoRead, MiRelay, diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 053ba99005a..d66f74bf893 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -88,6 +88,7 @@ export class ExportAntennasProcessorService { withReplies: antenna.withReplies, withFile: antenna.withFile, excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, + isPublic: antenna.isPublic, } satisfies Required)); if (antennas.length - 1 !== index) { write(', '); diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 4c7f2d09bbd..227848cfd42 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -49,6 +49,7 @@ const exportedAntennaSchema = { withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, excludeNotesInSensitiveChannel: { type: 'boolean' }, + isPublic: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const satisfies Schema; @@ -98,6 +99,7 @@ export class ImportAntennasProcessorService { withReplies: antenna.withReplies, withFile: antenna.withFile, excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, + isPublic: antenna.isPublic, }); this.logger.succ('Antenna created: ' + result.id); this.globalEventService.publishInternalEvent('antennaCreated', result); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index feb39e98bb3..104545e77ac 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -113,9 +113,12 @@ export * as 'announcements' from './endpoints/announcements.js'; export * as 'announcements/show' from './endpoints/announcements/show.js'; export * as 'antennas/create' from './endpoints/antennas/create.js'; export * as 'antennas/delete' from './endpoints/antennas/delete.js'; +export * as 'antennas/favorite' from './endpoints/antennas/favorite.js'; export * as 'antennas/list' from './endpoints/antennas/list.js'; +export * as 'antennas/my-favorites' from './endpoints/antennas/my-favorites.js'; export * as 'antennas/notes' from './endpoints/antennas/notes.js'; export * as 'antennas/show' from './endpoints/antennas/show.js'; +export * as 'antennas/unfavorite' from './endpoints/antennas/unfavorite.js'; export * as 'antennas/update' from './endpoints/antennas/update.js'; export * as 'ap/get' from './endpoints/ap/get.js'; export * as 'ap/show' from './endpoints/ap/show.js'; @@ -387,6 +390,7 @@ export * as 'test' from './endpoints/test.js'; export * as 'username/available' from './endpoints/username/available.js'; export * as 'users' from './endpoints/users.js'; export * as 'users/achievements' from './endpoints/users/achievements.js'; +export * as 'users/antennas' from './endpoints/users/antennas.js'; export * as 'users/clips' from './endpoints/users/clips.js'; export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index c075608491a..499662ceba1 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -74,6 +74,7 @@ export const paramDef = { withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, excludeNotesInSensitiveChannel: { type: 'boolean' }, + isPublic: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @@ -135,11 +136,12 @@ export default class extends Endpoint { // eslint- withReplies: ps.withReplies, withFile: ps.withFile, excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, + isPublic: ps.isPublic ?? false, }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); - return await this.antennaEntityService.pack(antenna); + return await this.antennaEntityService.pack(antenna, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/favorite.ts b/packages/backend/src/server/api/endpoints/antennas/favorite.ts new file mode 100644 index 00000000000..e6076db6447 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/favorite.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AntennasRepository, AntennaFavoritesRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:antenna-favorite', + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'a4d3b7f0-1c1e-4e16-9a8b-3a7e1d2f4b6a', + }, + + alreadyFavorited: { + message: 'The antenna has already been favorited.', + code: 'ALREADY_FAVORITED', + id: 'd2a4e1c6-3b5e-4a8d-9f0c-1e2d3f4a5b6c', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + antennaId: { type: 'string', format: 'misskey:id' }, + }, + required: ['antennaId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + if ((antenna.userId !== me.id) && !antenna.isPublic) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const exist = await this.antennaFavoritesRepository.exists({ + where: { + antennaId: antenna.id, + userId: me.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.antennaFavoritesRepository.insert({ + id: this.idService.gen(), + antennaId: antenna.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts index 83d29f9c8c8..5b1c1fe6b34 100644 --- a/packages/backend/src/server/api/endpoints/antennas/list.ts +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -46,7 +46,7 @@ export default class extends Endpoint { // eslint- userId: me.id, }); - return await Promise.all(antennas.map(x => this.antennaEntityService.pack(x))); + return await this.antennaEntityService.packMany(antennas, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/my-favorites.ts b/packages/backend/src/server/api/endpoints/antennas/my-favorites.ts new file mode 100644 index 00000000000..0db30313bfa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/my-favorites.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AntennaFavoritesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; + +export const meta = { + tags: ['account', 'antennas'], + + requireCredential: true, + + kind: 'read:antenna-favorite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Antenna', + }, + }, +} 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.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + + private antennaEntityService: AntennaEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const favorites = await this.antennaFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.antenna', 'antenna') + .getMany(); + + return this.antennaEntityService.packMany(favorites.map(x => x.antenna!), me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index c59479d3701..ad4801a19cf 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; @@ -21,7 +20,7 @@ import { ApiError } from '../../error.js'; export const meta = { tags: ['antennas', 'account', 'notes'], - requireCredential: true, + requireCredential: false, kind: 'read:account', @@ -79,13 +78,16 @@ export default class extends Endpoint { // eslint- const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId, - userId: me.id, }); if (antenna == null) { throw new ApiError(meta.errors.noSuchAntenna); } + if (!antenna.isPublic && (me == null || antenna.userId !== me.id)) { + throw new ApiError(meta.errors.noSuchAntenna); + } + // falseだった場合はアンテナの配信先が増えたことを通知したい const needPublishEvent = !antenna.isActive; @@ -111,19 +113,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - // -- ミュートされたチャンネル対策 - const mutingChannelIds = await this.channelMutingService - .list({ requestUserId: me.id }, { idOnly: true }) - .then(x => x.map(x => x.id)); - if (mutingChannelIds.length > 0) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteChannelId IS NULL'); - qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); + // -- ミュートされたチャンネル対策 (ログインユーザのみ) + if (me) { + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } } // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts index a40f187d0bc..bbaf63b8c42 100644 --- a/packages/backend/src/server/api/endpoints/antennas/show.ts +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -13,7 +13,7 @@ import { ApiError } from '../../error.js'; export const meta = { tags: ['antennas', 'account'], - requireCredential: true, + requireCredential: false, kind: 'read:account', @@ -49,17 +49,19 @@ export default class extends Endpoint { // eslint- private antennaEntityService: AntennaEntityService, ) { super(meta, paramDef, async (ps, me) => { - // Fetch the antenna const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId, - userId: me.id, }); if (antenna == null) { throw new ApiError(meta.errors.noSuchAntenna); } - return await this.antennaEntityService.pack(antenna); + if (!antenna.isPublic && (me == null || antenna.userId !== me.id)) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await this.antennaEntityService.pack(antenna, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/unfavorite.ts b/packages/backend/src/server/api/endpoints/antennas/unfavorite.ts new file mode 100644 index 00000000000..4c9d09985db --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/unfavorite.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AntennasRepository, AntennaFavoritesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:antenna-favorite', + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b', + }, + + notFavorited: { + message: 'You have not favorited the antenna.', + code: 'NOT_FAVORITED', + id: 'b6a7c8d9-e0f1-4a2b-9c3d-4e5f6a7b8c9d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + antennaId: { type: 'string', format: 'misskey:id' }, + }, + required: ['antennaId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const exist = await this.antennaFavoritesRepository.findOneBy({ + antennaId: antenna.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.antennaFavoritesRepository.delete(exist.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 53fc4db1b77..612fffcabb1 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -73,6 +73,7 @@ export const paramDef = { withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, excludeNotesInSensitiveChannel: { type: 'boolean' }, + isPublic: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -131,13 +132,14 @@ export default class extends Endpoint { // eslint- withReplies: ps.withReplies, withFile: ps.withFile, excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, + isPublic: ps.isPublic, isActive: true, lastUsedAt: new Date(), }); this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id })); - return await this.antennaEntityService.pack(antenna.id); + return await this.antennaEntityService.pack(antenna.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/antennas.ts b/packages/backend/src/server/api/endpoints/users/antennas.ts new file mode 100644 index 00000000000..d36fc5375ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/antennas.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AntennasRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['users', 'antennas'], + + description: 'Show all public antennas this user owns.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Antenna', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + private antennaEntityService: AntennaEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.antennasRepository.createQueryBuilder('antenna'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('antenna.userId = :userId', { userId: ps.userId }) + .andWhere('antenna.isPublic = true'); + + const antennas = await query + .limit(ps.limit) + .getMany(); + + return await this.antennaEntityService.packMany(antennas, me); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index b7f863b3551..3eef21a38bb 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -44,14 +44,12 @@ export class AntennaChannel extends Channel { this.antennaId = params.antennaId; - const antennaExists = await this.antennasReposiotry.exists({ - where: { - id: this.antennaId, - userId: this.user.id, - }, + const antenna = await this.antennasReposiotry.findOneBy({ + id: this.antennaId, }); - if (!antennaExists) return false; + if (antenna == null) return false; + if (!antenna.isPublic && antenna.userId !== this.user.id) return false; // Subscribe stream this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index ea7cd77d665..dfd659e72c9 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -167,6 +167,11 @@ describe('アンテナ', () => { excludeBots: false, localOnly: false, notify: false, + isPublic: false, + userId: response.userId, + user: response.user, + favoritedCount: response.favoritedCount, + isFavorited: response.isFavorited, }; assert.deepStrictEqual(response, expected); }); @@ -728,4 +733,406 @@ describe('アンテナ', () => { //#endregion }); + + //#region 共有アンテナ (isPublic, favorite) + + describe('の共有 (isPublic)', () => { + const sharedKeyword = 'sharedAntennaKeyword'; + + describe('作成・更新で isPublic を任意の src と組み合わせられる', () => { + test.each([ + { src: 'all' as const }, + { src: 'home' as const }, + { src: 'users' as const }, + { src: 'users_blacklist' as const }, + { src: 'list' as const, userListId: () => aliceList.id }, + ])('src=$src で isPublic=true として作成できる', async ({ src, userListId }) => { + const response = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, src, userListId: userListId ? userListId() : null, isPublic: true }, + user: alice, + }); + assert.strictEqual(response.src, src); + assert.strictEqual(response.isPublic, true); + }); + + test('既存の公開アンテナ (src=all) を src=users に変更しても公開状態を維持できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, src: 'users', isPublic: true }, + user: alice, + }); + assert.strictEqual(response.src, 'users'); + assert.strictEqual(response.isPublic, true); + }); + + test('既存の src=list アンテナを isPublic=true に変更できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, src: 'list', userListId: aliceList.id }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: aliceList.id, isPublic: true }, + user: alice, + }); + assert.strictEqual(response.src, 'list'); + assert.strictEqual(response.isPublic, true); + }); + }); + + describe('アクセス制御', () => { + test('公開アンテナは他ユーザでも show できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.strictEqual(response.id, antenna.id); + assert.strictEqual(response.isPublic, true); + assert.strictEqual(response.userId, alice.id); + }); + + test('公開アンテナは未ログインでも show できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: undefined, + }); + assert.strictEqual(response.id, antenna.id); + }); + + test('非公開アンテナは他ユーザだと NO_SUCH_ANTENNA', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: 'c06569fb-b025-4f23-b22d-1fcd20d2816b', + }); + }); + + test('他人の公開アンテナは update/delete できない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, isPublic: true, name: 'hijack' }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: '10c673ac-8852-48eb-aa1f-f5b67f069290', + }); + await failedApiCall({ + endpoint: 'antennas/delete', + parameters: { antennaId: antenna.id }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df', + }); + }); + + test('users/antennas は対象ユーザの公開アンテナだけを返す', async () => { + const publicAntenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, name: 'public-1' }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: false, name: 'private-1' }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'users/antennas', + parameters: { userId: alice.id }, + user: carol, + }); + assert.strictEqual(response.length, 1); + assert.strictEqual(response[0].id, publicAntenna.id); + }); + }); + + describe('ノートの可視性 (漏洩検証)', () => { + test('所有者宛の followers 限定ノートは、投稿者をフォローしていない購読者には漏れない', async () => { + // alice は userFollowedByAlice をフォローしている (beforeAll で設定済) + // carol は userFollowedByAlice をフォローしていない + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + const note = await post(userFollowedByAlice, { text: `${sharedKeyword} followers-only`, visibility: 'followers' }); + + // alice (所有者) 視点では含まれる + const aliceView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.ok(aliceView.some(n => n.id === note.id), 'owner should see the followers-only note'); + + // carol (購読者・未フォロー) 視点では漏れない + const carolView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.ok(!carolView.some(n => n.id === note.id), 'non-follower subscriber must not see the followers-only note'); + }); + + test('所有者宛の specified ノートは、宛先でない購読者には漏れない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + const note = await post(bob, { + text: `${sharedKeyword} dm`, + visibility: 'specified', + visibleUserIds: [alice.id], + }); + + const aliceView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.ok(aliceView.some(n => n.id === note.id), 'owner should see specified note'); + + const carolView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.ok(!carolView.some(n => n.id === note.id), 'unrelated subscriber must not see specified note'); + }); + + test('投稿者が購読者をブロックしている場合、そのノートは含まれない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + // bob が carol をブロックする + await api('blocking/create', { userId: carol.id }, bob); + const note = await post(bob, { text: `${sharedKeyword} bob-blocks-carol` }); + + const carolView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.ok(!carolView.some(n => n.id === note.id), 'note from a user who blocked the subscriber must not be visible'); + + // 後片付け + await api('blocking/delete', { userId: carol.id }, bob); + }); + + test('公開アンテナのノートは未ログインでも公開ノートのみ取得できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + const publicNote = await post(bob, { text: `${sharedKeyword} public` }); + const followersNote = await post(userFollowedByAlice, { text: `${sharedKeyword} followers`, visibility: 'followers' }); + + const anonView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: undefined, + }); + assert.ok(anonView.some(n => n.id === publicNote.id), 'anon should see public note'); + assert.ok(!anonView.some(n => n.id === followersNote.id), 'anon must not see followers-only note'); + }); + + test('非公開アンテナのノートは他ユーザだと取得できない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[sharedKeyword]] }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe', + }); + }); + }); + + describe('お気に入り (favorite)', () => { + test('公開アンテナを他ユーザがお気に入りできる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + const shown = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.strictEqual(shown.isFavorited, true); + assert.strictEqual(shown.favoritedCount, 1); + }); + + test('非公開アンテナを他ユーザはお気に入りできない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: 'a4d3b7f0-1c1e-4e16-9a8b-3a7e1d2f4b6a', + }); + }); + + test('自分のアンテナはお気に入り可 (公開非公開問わず)', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: alice, + }); + }); + + test('重複 favorite はエラー', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + await failedApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'ALREADY_FAVORITED', + id: 'd2a4e1c6-3b5e-4a8d-9f0c-1e2d3f4a5b6c', + }); + }); + + test('unfavorite で取り消せる / 未 favorite で NOT_FAVORITED', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/unfavorite', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NOT_FAVORITED', + id: 'b6a7c8d9-e0f1-4a2b-9c3d-4e5f6a7b8c9d', + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + await successfulApiCall({ + endpoint: 'antennas/unfavorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + const shown = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.strictEqual(shown.isFavorited, false); + assert.strictEqual(shown.favoritedCount, 0); + }); + + test('my-favorites でお気に入りした公開アンテナの一覧が取れる', async () => { + const a1 = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, name: 'fav-1' }, + user: alice, + }); + const a2 = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, name: 'fav-2' }, + user: bob, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: a1.id }, + user: carol, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: a2.id }, + user: carol, + }); + const favs = await successfulApiCall({ + endpoint: 'antennas/my-favorites', + parameters: {}, + user: carol, + }); + const ids = new Set(favs.map(x => x.id)); + assert.ok(ids.has(a1.id)); + assert.ok(ids.has(a2.id)); + }); + }); + }); + + //#endregion }); diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 39cf73feb85..d465d6c8e2c 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -11,4 +11,5 @@ export const clipsCache = new Cache(1000 * 60 * 30, () export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list', { limit: 30 })); export const userListsCache = new Cache(1000 * 60 * 30, () => misskeyApi('users/lists/list')); export const antennasCache = new Cache(1000 * 60 * 30, () => misskeyApi('antennas/list', { limit: 30 })); +export const favoritedAntennasCache = new Cache(1000 * 60 * 30, () => misskeyApi('antennas/my-favorites')); export const favoritedChannelsCache = new Cache(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 })); diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index a41fdbc45d6..fbf543dbc1e 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -34,6 +34,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.caseSensitive }} {{ i18n.ts.withFileAntenna }} {{ i18n.ts.excludeNotesInSensitiveChannel }} + + {{ i18n.ts._antenna.public }} + +
@@ -60,7 +67,7 @@ import { i18n } from '@/i18n.js'; import { deepMerge } from '@/utility/merge.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; -type PartialAllowedAntenna = Omit & { +type PartialAllowedAntenna = Omit & { id?: string; createdAt?: string; updatedAt?: string; @@ -86,6 +93,7 @@ const initialAntenna = deepMerge(props.antenna ?? {}, { isActive: true, hasUnreadNote: false, notify: false, + isPublic: false, }); const emit = defineEmits<{ @@ -132,6 +140,7 @@ const excludeBots = ref(initialAntenna.excludeBots); const withReplies = ref(initialAntenna.withReplies); const withFile = ref(initialAntenna.withFile); const excludeNotesInSensitiveChannel = ref(initialAntenna.excludeNotesInSensitiveChannel); +const isPublic = ref(initialAntenna.isPublic); const userLists = ref(null); watch(() => src.value, async () => { @@ -151,6 +160,7 @@ async function saveAntenna() { excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, + isPublic: isPublic.value, users: users.value.trim().split('\n').map(x => x.trim()), keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')), excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')), @@ -197,4 +207,10 @@ function addUser() { padding: 24px 0; border-top: solid 0.5px var(--MI_THEME-divider); } + +.publicConditionsExposed { + display: block; + margin-top: 4px; + color: var(--MI_THEME-fgTransparentWeak); +} diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 9030fa0e294..e39e8574642 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only