From 9bc5d52e413dcf35e85613e0c5a5b319ba95017a Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
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<Packed<'Channel'>> {
 		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<AuthSession>;
 export type BlockingsRepository = Repository<Blocking>;
 export type ChannelFollowingsRepository = Repository<ChannelFollowing>;
 export type ChannelFavoritesRepository = Repository<ChannelFavorite>;
-export type ChannelNotePiningsRepository = Repository<ChannelNotePining>;
 export type ClipsRepository = Repository<Clip>;
 export type ClipNotesRepository = Repository<ClipNote>;
 export type ClipFavoritesRepository = Repository<ClipFavorite>;
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<typeof meta, typeof paramDef> {
 				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<typeof meta, typeof paramDef> {
 		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<typeof meta, typeof paramDef> {
 			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<Ref<misskey.entities.Clip> | null>('currentClip', null);
 
 let note = $ref(deepClone(props.note));
 
@@ -370,8 +371,6 @@ function undoReact(note): void {
 	});
 }
 
-const currentClipPage = inject<Ref<misskey.entities.Clip> | 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 @@
 <MkStickyContainer>
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="700">
-		<div class="_gaps_m">
+		<div v-if="channel" class="_gaps_m">
 			<MkInput v-model="name">
 				<template #label>{{ i18n.ts.name }}</template>
 			</MkInput>
@@ -11,13 +11,37 @@
 				<template #label>{{ i18n.ts.description }}</template>
 			</MkTextarea>
 
-			<div class="banner">
+			<div>
 				<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton>
 				<div v-else-if="bannerUrl">
 					<img :src="bannerUrl" style="width: 100%;"/>
 					<MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton>
 				</div>
 			</div>
+
+			<MkFolder :default-open="true">
+				<template #label>{{ i18n.ts.pinnedNotes }}</template>
+				
+				<div class="_gaps">
+					<MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton>
+
+					<Sortable 
+						v-model="pinnedNotes"
+						item-key="id"
+						:handle="'.' + $style.pinnedNoteHandle"
+						:animation="150"
+					>
+						<template #item="{element,index}">
+							<div :class="$style.pinnedNote">
+								<button class="_button" :class="$style.pinnedNoteHandle"><i class="ti ti-menu"></i></button>
+								{{ element.id }}
+								<button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(index)"><i class="ti ti-x"></i></button>
+							</div>
+						</template>
+					</Sortable>
+				</div>
+			</MkFolder>
+
 			<div>
 				<MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton>
 			</div>
@@ -27,7 +51,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, watch } from 'vue';
+import { computed, ref, watch, defineAsyncComponent } from 'vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -36,6 +60,9 @@ import * as os from '@/os';
 import { useRouter } from '@/router';
 import { definePageMetadata } from '@/scripts/page-metadata';
 import { i18n } from '@/i18n';
+import MkFolder from '@/components/MkFolder.vue';
+
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
 const router = useRouter();
 
@@ -48,6 +75,7 @@ let name = $ref(null);
 let description = $ref(null);
 let bannerUrl = $ref<string | null>(null);
 let bannerId = $ref<string | null>(null);
+const pinnedNotes = ref([]);
 
 watch(() => bannerId, async () => {
 	if (bannerId == null) {
@@ -70,15 +98,36 @@ async function fetchChannel() {
 	description = channel.description;
 	bannerId = channel.bannerId;
 	bannerUrl = channel.bannerUrl;
+	pinnedNotes.value = channel.pinnedNoteIds.map(id => ({
+		id,
+	}));
 }
 
 fetchChannel();
 
+async function addPinnedNote() {
+	const { canceled, result: value } = await os.inputText({
+		title: i18n.ts.noteIdOrUrl,
+	});
+	if (canceled) return;
+	const note = await os.apiWithDialog('notes/show', {
+		noteId: value.includes('/') ? value.split('/').pop() : value,
+	});
+	pinnedNotes.value = [{
+		id: note.id,
+	}, ...pinnedNotes.value];
+}
+
+function removePinnedNote(index: number) {
+	pinnedNotes.value.splice(index, 1);
+}
+
 function save() {
 	const params = {
 		name: name,
 		description: description,
 		bannerId: bannerId,
+		pinnedNoteIds: pinnedNotes.value.map(x => x.id),
 	};
 
 	if (props.channelId) {
@@ -117,6 +166,32 @@ definePageMetadata(computed(() => props.channelId ? {
 }));
 </script>
 
-<style lang="scss" scoped>
+<style lang="scss" module>
+.pinnedNote {
+	position: relative;
+	display: block;
+	line-height: 2.85rem;
+	text-overflow: ellipsis;
+	overflow: hidden;
+	white-space: nowrap;
+	color: var(--navFg);
+}
 
+.pinnedNoteRemove {
+	position: absolute;
+	z-index: 10000;
+	width: 32px;
+	height: 32px;
+	color: #ff2a2a;
+	right: 8px;
+	opacity: 0.8;
+}
+
+.pinnedNoteHandle {
+	cursor: move;
+	width: 32px;
+	height: 32px;
+	margin: 0 8px;
+	opacity: 0.5;
+}
 </style>
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 0fee2181db..47ca8003ad 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -19,6 +19,13 @@
 
 			<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
 			<MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-star"></i></MkButton>
+
+			<MkFoldableSection>
+				<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
+				<div v-if="channel.pinnedNotes.length > 0" class="_gaps">
+					<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/>
+				</div>
+			</MkFoldableSection>
 		</div>
 		<div v-if="channel && tab === 'timeline'" class="_gaps">
 			<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
@@ -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<boolean>;
-	currentClipPage?: Ref<misskey.entities.Clip>;
+	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<any>;
 	translating: Ref<boolean>;
 	isDeleted: Ref<boolean>;
-	currentClipPage?: Ref<misskey.entities.Clip>;
+	currentClip?: misskey.entities.Clip;
 }) {
 	const isRenote = (
 		props.note.renote != null &&
@@ -176,7 +176,7 @@ export function getNoteMenu(props: {
 	}
 
 	async function unclip(): Promise<void> {
-		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),