From 9bc5d52e413dcf35e85613e0c5a5b319ba95017a Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 31 Mar 2023 15:01:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=81=E3=83=A3=E3=83=B3=E3=83=8D?= =?UTF-8?q?=E3=83=AB=E3=81=AB=E3=83=8E=E3=83=BC=E3=83=88=E3=82=92=E3=83=94?= =?UTF-8?q?=E3=83=B3=E7=95=99=E3=82=81=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve #7740 --- CHANGELOG.md | 1 + locales/ja-JP.yml | 1 + .../1680238118084-channelNotePining.js | 11 +++ .../src/core/entities/ChannelEntityService.ts | 22 ++++- packages/backend/src/di-symbols.ts | 1 - .../backend/src/models/RepositoryModule.ts | 10 +-- .../backend/src/models/entities/Channel.ts | 5 ++ .../src/models/entities/ChannelNotePining.ts | 35 -------- packages/backend/src/models/index.ts | 3 - .../backend/src/models/json-schema/channel.ts | 8 ++ packages/backend/src/postgres.ts | 2 - .../src/server/api/endpoints/channels/show.ts | 2 +- .../server/api/endpoints/channels/update.ts | 11 ++- packages/frontend/src/components/MkNote.vue | 9 +- .../frontend/src/pages/channel-editor.vue | 83 ++++++++++++++++++- packages/frontend/src/pages/channel.vue | 9 ++ packages/frontend/src/pages/clip.vue | 2 +- .../frontend/src/scripts/get-note-menu.ts | 12 +-- 18 files changed, 155 insertions(+), 72 deletions(-) create mode 100644 packages/backend/migration/1680238118084-channelNotePining.js delete mode 100644 packages/backend/src/models/entities/ChannelNotePining.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a21a2728..711d9db62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### General - チャンネルをお気に入りに登録できるように +- チャンネルにノートをピン留めできるように ### Client - 検索ページでURLを入力した際に照会したときと同等の挙動をするように diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 67f0634346..a9c54810ac 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -984,6 +984,7 @@ enableChartsForRemoteUser: "リモートユーザーのチャートを生成" enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" largeNoteReactions: "ノートのリアクションを大きく表示" +noteIdOrUrl: "ノートIDまたはURL" _achievements: earnedAt: "獲得日時" diff --git a/packages/backend/migration/1680238118084-channelNotePining.js b/packages/backend/migration/1680238118084-channelNotePining.js new file mode 100644 index 0000000000..126eae87ea --- /dev/null +++ b/packages/backend/migration/1680238118084-channelNotePining.js @@ -0,0 +1,11 @@ +export class channelNotePining1680238118084 { + name = 'channelNotePining1680238118084' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "pinnedNoteIds" character varying(128) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "pinnedNoteIds"`); + } +} diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 97bf66095c..72e9b25544 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -1,13 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; +import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Channel } from '@/models/entities/Channel.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; +import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { @@ -21,13 +22,16 @@ export class ChannelEntityService { @Inject(DI.channelFavoritesRepository) private channelFavoritesRepository: ChannelFavoritesRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, ) { } @@ -36,6 +40,7 @@ export class ChannelEntityService { public async pack( src: Channel['id'] | Channel, me?: { id: User['id'] } | null | undefined, + detailed?: boolean, ): Promise> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); const meId = me ? me.id : null; @@ -54,6 +59,12 @@ export class ChannelEntityService { channelId: channel.id, }) : null; + const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ + where: { + id: In(channel.pinnedNoteIds), + }, + }) : []; + return { id: channel.id, createdAt: channel.createdAt.toISOString(), @@ -62,6 +73,7 @@ export class ChannelEntityService { description: channel.description, userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + pinnedNoteIds: channel.pinnedNoteIds, usersCount: channel.usersCount, notesCount: channel.notesCount, @@ -70,6 +82,10 @@ export class ChannelEntityService { isFavorited: favorite != null, hasUnreadNote, } : {}), + + ...(detailed ? { + pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), + } : {}), }; } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 6da31b9a4b..4f475a03ad 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -62,7 +62,6 @@ export const DI = { channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'), - channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), adsRepository: Symbol('adsRepository'), diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index a007a1ae87..da7faf9ffb 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -346,12 +346,6 @@ const $channelFavoritesRepository: Provider = { inject: [DI.db], }; -const $channelNotePiningsRepository: Provider = { - provide: DI.channelNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), - inject: [DI.db], -}; - const $registryItemsRepository: Provider = { provide: DI.registryItemsRepository, useFactory: (db: DataSource) => db.getRepository(RegistryItem), @@ -467,7 +461,6 @@ const $roleAssignmentsRepository: Provider = { $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, - $channelNotePiningsRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, @@ -536,7 +529,6 @@ const $roleAssignmentsRepository: Provider = { $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, - $channelNotePiningsRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts index a6e32d54f7..2d346fdf9d 100644 --- a/packages/backend/src/models/entities/Channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -59,6 +59,11 @@ export class Channel { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public pinnedNoteIds: string[]; + @Index() @Column('integer', { default: 0, diff --git a/packages/backend/src/models/entities/ChannelNotePining.ts b/packages/backend/src/models/entities/ChannelNotePining.ts deleted file mode 100644 index ab5796626a..0000000000 --- a/packages/backend/src/models/entities/ChannelNotePining.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from '../id.js'; -import { Note } from './Note.js'; -import { Channel } from './Channel.js'; - -@Entity() -@Index(['channelId', 'noteId'], { unique: true }) -export class ChannelNotePining { - @PrimaryColumn(id()) - public id: string; - - @Column('timestamp with time zone', { - comment: 'The created date of the ChannelNotePining.', - }) - public createdAt: Date; - - @Index() - @Column(id()) - public channelId: Channel['id']; - - @ManyToOne(type => Channel, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public channel: Channel | null; - - @Column(id()) - public noteId: Note['id']; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; -} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 872cbbcbac..79bd014cea 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -11,7 +11,6 @@ import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; @@ -81,7 +80,6 @@ export { Blocking, ChannelFollowing, ChannelFavorite, - ChannelNotePining, Clip, ClipNote, ClipFavorite, @@ -150,7 +148,6 @@ export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; export type ChannelFavoritesRepository = Repository; -export type ChannelNotePiningsRepository = Repository; export type ClipsRepository = Repository; export type ClipNotesRepository = Repository; export type ClipFavoritesRepository = Repository; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index f90e959926..745b39a6ba 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -51,5 +51,13 @@ export const packedChannelSchema = { nullable: true, optional: false, format: 'id', }, + pinnedNoteIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + format: 'id', + }, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index edcf344561..cbe3814a24 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -19,7 +19,6 @@ import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; @@ -177,7 +176,6 @@ export const entities = [ Channel, ChannelFollowing, ChannelFavorite, - ChannelNotePining, RegistryItem, Ad, PasswordResetRequest, diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index 8718615db2..070d14631e 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - return await this.channelEntityService.pack(channel, me); + return await this.channelEntityService.pack(channel, me, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index a86cc2565a..084b3f919e 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -3,8 +3,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['channels'], @@ -47,6 +47,12 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + pinnedNoteIds: { + type: 'array', + items: { + type: 'string', format: 'misskey:id', + }, + }, }, required: ['channelId'], } as const; @@ -64,7 +70,7 @@ export default class extends Endpoint { private channelEntityService: ChannelEntityService, private roleService: RoleService, - ) { + ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, @@ -97,6 +103,7 @@ export default class extends Endpoint { await this.channelsRepository.update(channel.id, { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), ...(banner ? { bannerId: banner.id } : {}), }); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 72c6e55df1..eb9793fcc1 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -169,6 +169,7 @@ const props = defineProps<{ }>(); const inChannel = inject('inChannel', null); +const currentClip = inject | null>('currentClip', null); let note = $ref(deepClone(props.note)); @@ -370,8 +371,6 @@ function undoReact(note): void { }); } -const currentClipPage = inject | null>('currentClipPage', null); - function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; @@ -386,18 +385,18 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), ev).then(focus); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, { viaKeyboard, }).then(focus); } async function clip() { - os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClipPage }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 38c5b1e082..667caab966 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -2,7 +2,7 @@ -
+
@@ -11,13 +11,37 @@ -
@@ -57,6 +64,8 @@ import MkNotes from '@/components/MkNotes.vue'; import { url } from '@/config'; import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store'; +import MkNote from '@/components/MkNote.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; const router = useRouter(); diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 2b64de088a..e3ac3f4c9b 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -57,7 +57,7 @@ watch(() => props.clipId, async () => { immediate: true, }); -provide('currentClipPage', $$(clip)); +provide('currentClip', $$(clip)); function favorite() { os.apiWithDialog('clips/favorite', { diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 00f2523bf9..d91f0b0eb6 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -15,7 +15,7 @@ import { clipsCache } from '@/cache'; export async function getNoteClipMenu(props: { note: misskey.entities.Note; isDeleted: Ref; - currentClipPage?: Ref; + currentClip?: misskey.entities.Clip; }) { const isRenote = ( props.note.renote != null && @@ -42,7 +42,7 @@ export async function getNoteClipMenu(props: { }); if (!confirm.canceled) { os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); - if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + if (props.currentClip?.id === clip.id) props.isDeleted.value = true; } } else { os.alert({ @@ -92,7 +92,7 @@ export function getNoteMenu(props: { translation: Ref; translating: Ref; isDeleted: Ref; - currentClipPage?: Ref; + currentClip?: misskey.entities.Clip; }) { const isRenote = ( props.note.renote != null && @@ -176,7 +176,7 @@ export function getNoteMenu(props: { } async function unclip(): Promise { - os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); + os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); props.isDeleted.value = true; } @@ -230,7 +230,7 @@ export function getNoteMenu(props: { menu = [ ...( - props.currentClipPage?.value.userId === $i.id ? [{ + props.currentClip?.userId === $i.id ? [{ icon: 'ti ti-backspace', text: i18n.ts.unclip, danger: true, @@ -294,7 +294,7 @@ export function getNoteMenu(props: { text: i18n.ts.muteThread, action: () => toggleThreadMute(true), }), - appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? { icon: 'ti ti-pinned-off', text: i18n.ts.unpin, action: () => togglePin(false),