From 3cb0cc798914ff9057f4032a9b79e21402f72ec8 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 31 Mar 2023 11:30:27 +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=82=92=E3=81=8A=E6=B0=97=E3=81=AB=E5=85=A5=E3=82=8A?=
 =?UTF-8?q?=E3=81=AB=E7=99=BB=E9=8C=B2=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 #10097
---
 CHANGELOG.md                                  |  2 +-
 .../1680228513388-channelFavorite.js          | 21 +++++++
 .../src/core/entities/ChannelEntityService.ts | 11 +++-
 packages/backend/src/di-symbols.ts            |  1 +
 .../backend/src/models/RepositoryModule.ts    | 10 ++-
 .../src/models/entities/ChannelFavorite.ts    | 41 +++++++++++++
 packages/backend/src/models/index.ts          |  3 +
 .../backend/src/models/json-schema/channel.ts |  4 ++
 packages/backend/src/postgres.ts              |  2 +
 .../backend/src/server/api/EndpointsModule.ts | 12 ++++
 packages/backend/src/server/api/endpoints.ts  |  6 ++
 .../server/api/endpoints/channels/favorite.ts | 61 +++++++++++++++++++
 .../api/endpoints/channels/my-favorites.ts    | 54 ++++++++++++++++
 .../api/endpoints/channels/unfavorite.ts      | 56 +++++++++++++++++
 packages/frontend/src/pages/channel.vue       | 26 ++++++++
 packages/frontend/src/pages/channels.vue      | 25 ++++++--
 packages/frontend/src/pages/timeline.vue      |  2 +-
 17 files changed, 327 insertions(+), 10 deletions(-)
 create mode 100644 packages/backend/migration/1680228513388-channelFavorite.js
 create mode 100644 packages/backend/src/models/entities/ChannelFavorite.ts
 create mode 100644 packages/backend/src/server/api/endpoints/channels/favorite.ts
 create mode 100644 packages/backend/src/server/api/endpoints/channels/my-favorites.ts
 create mode 100644 packages/backend/src/server/api/endpoints/channels/unfavorite.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index be47a3db2..d3a21a272 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,7 @@
 ## 13.x.x (unreleased)
 
 ### General
--
+- チャンネルをお気に入りに登録できるように
 
 ### Client
 - 検索ページでURLを入力した際に照会したときと同等の挙動をするように
diff --git a/packages/backend/migration/1680228513388-channelFavorite.js b/packages/backend/migration/1680228513388-channelFavorite.js
new file mode 100644
index 000000000..afc676959
--- /dev/null
+++ b/packages/backend/migration/1680228513388-channelFavorite.js
@@ -0,0 +1,21 @@
+export class channelFavorite1680228513388 {
+    name = 'channelFavorite1680228513388'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE TABLE "channel_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_59bddfd54d48689a298d41af00c" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_favorite"."createdAt" IS 'The created date of the ChannelFavorite.'`);
+        await queryRunner.query(`CREATE INDEX "IDX_735a5544f9249d412255f47f95" ON "channel_favorite" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_d3ca0db011b75ac2a940a2337d" ON "channel_favorite" ("channelId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_8302bd27226605ece14842fb25" ON "channel_favorite" ("userId") `);
+        await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_8302bd27226605ece14842fb25a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_8302bd27226605ece14842fb25a"`);
+        await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_8302bd27226605ece14842fb25"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_d3ca0db011b75ac2a940a2337d"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_735a5544f9249d412255f47f95"`);
+        await queryRunner.query(`DROP TABLE "channel_favorite"`);
+    }
+}
diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts
index 6048492f0..97bf66095 100644
--- a/packages/backend/src/core/entities/ChannelEntityService.ts
+++ b/packages/backend/src/core/entities/ChannelEntityService.ts
@@ -1,6 +1,6 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
-import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js';
+import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } 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';
@@ -18,6 +18,9 @@ export class ChannelEntityService {
 		@Inject(DI.channelFollowingsRepository)
 		private channelFollowingsRepository: ChannelFollowingsRepository,
 
+		@Inject(DI.channelFavoritesRepository)
+		private channelFavoritesRepository: ChannelFavoritesRepository,
+
 		@Inject(DI.noteUnreadsRepository)
 		private noteUnreadsRepository: NoteUnreadsRepository,
 
@@ -46,6 +49,11 @@ export class ChannelEntityService {
 			followeeId: channel.id,
 		}) : null;
 
+		const favorite = meId ? await this.channelFavoritesRepository.findOneBy({
+			userId: meId,
+			channelId: channel.id,
+		}) : null;
+
 		return {
 			id: channel.id,
 			createdAt: channel.createdAt.toISOString(),
@@ -59,6 +67,7 @@ export class ChannelEntityService {
 
 			...(me ? {
 				isFollowing: following != null,
+				isFavorited: favorite != null,
 				hasUnreadNote,
 			} : {}),
 		};
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index 0879735b1..6da31b9a4 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -61,6 +61,7 @@ export const DI = {
 	mutedNotesRepository: Symbol('mutedNotesRepository'),
 	channelsRepository: Symbol('channelsRepository'),
 	channelFollowingsRepository: Symbol('channelFollowingsRepository'),
+	channelFavoritesRepository: Symbol('channelFavoritesRepository'),
 	channelNotePiningsRepository: Symbol('channelNotePiningsRepository'),
 	registryItemsRepository: Symbol('registryItemsRepository'),
 	webhooksRepository: Symbol('webhooksRepository'),
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index d00c8813c..a007a1ae8 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, 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, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js';
 import type { DataSource } from 'typeorm';
 import type { Provider } from '@nestjs/common';
 
@@ -340,6 +340,12 @@ const $channelFollowingsRepository: Provider = {
 	inject: [DI.db],
 };
 
+const $channelFavoritesRepository: Provider = {
+	provide: DI.channelFavoritesRepository,
+	useFactory: (db: DataSource) => db.getRepository(ChannelFavorite),
+	inject: [DI.db],
+};
+
 const $channelNotePiningsRepository: Provider = {
 	provide: DI.channelNotePiningsRepository,
 	useFactory: (db: DataSource) => db.getRepository(ChannelNotePining),
@@ -460,6 +466,7 @@ const $roleAssignmentsRepository: Provider = {
 		$mutedNotesRepository,
 		$channelsRepository,
 		$channelFollowingsRepository,
+		$channelFavoritesRepository,
 		$channelNotePiningsRepository,
 		$registryItemsRepository,
 		$webhooksRepository,
@@ -528,6 +535,7 @@ const $roleAssignmentsRepository: Provider = {
 		$mutedNotesRepository,
 		$channelsRepository,
 		$channelFollowingsRepository,
+		$channelFavoritesRepository,
 		$channelNotePiningsRepository,
 		$registryItemsRepository,
 		$webhooksRepository,
diff --git a/packages/backend/src/models/entities/ChannelFavorite.ts b/packages/backend/src/models/entities/ChannelFavorite.ts
new file mode 100644
index 000000000..cfb2c892c
--- /dev/null
+++ b/packages/backend/src/models/entities/ChannelFavorite.ts
@@ -0,0 +1,41 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from '../id.js';
+import { User } from './User.js';
+import { Channel } from './Channel.js';
+
+@Entity()
+@Index(['userId', 'channelId'], { unique: true })
+export class ChannelFavorite {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the ChannelFavorite.',
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public channelId: Channel['id'];
+
+	@ManyToOne(type => Channel, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public channel: Channel | null;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user: User | null;
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index 17083d7a0..872cbbcba 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -10,6 +10,7 @@ import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'
 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';
@@ -79,6 +80,7 @@ export {
 	AuthSession,
 	Blocking,
 	ChannelFollowing,
+	ChannelFavorite,
 	ChannelNotePining,
 	Clip,
 	ClipNote,
@@ -147,6 +149,7 @@ export type AttestationChallengesRepository = Repository<AttestationChallenge>;
 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>;
diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts
index 7f4f2a48b..f90e95992 100644
--- a/packages/backend/src/models/json-schema/channel.ts
+++ b/packages/backend/src/models/json-schema/channel.ts
@@ -42,6 +42,10 @@ export const packedChannelSchema = {
 			type: 'boolean',
 			optional: true, nullable: false,
 		},
+		isFavorited: {
+			type: 'boolean',
+			optional: true, nullable: false,
+		},
 		userId: {
 			type: 'string',
 			nullable: true, optional: false,
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index d5428805d..edcf34456 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -18,6 +18,7 @@ import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'
 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';
@@ -175,6 +176,7 @@ export const entities = [
 	MutedNote,
 	Channel,
 	ChannelFollowing,
+	ChannelFavorite,
 	ChannelNotePining,
 	RegistryItem,
 	Ad,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 835e88419..f39643abe 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -95,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js';
 import * as ep___channels_timeline from './endpoints/channels/timeline.js';
 import * as ep___channels_unfollow from './endpoints/channels/unfollow.js';
 import * as ep___channels_update from './endpoints/channels/update.js';
+import * as ep___channels_favorite from './endpoints/channels/favorite.js';
+import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
+import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
 import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
 import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
 import * as ep___charts_drive from './endpoints/charts/drive.js';
@@ -424,6 +427,9 @@ const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___c
 const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default };
 const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default };
 const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default };
+const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default };
+const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default };
+const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default };
 const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default };
 const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default };
 const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default };
@@ -757,6 +763,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$channels_timeline,
 		$channels_unfollow,
 		$channels_update,
+		$channels_favorite,
+		$channels_unfavorite,
+		$channels_myFavorites,
 		$charts_activeUsers,
 		$charts_apRequest,
 		$charts_drive,
@@ -1084,6 +1093,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$channels_timeline,
 		$channels_unfollow,
 		$channels_update,
+		$channels_favorite,
+		$channels_unfavorite,
+		$channels_myFavorites,
 		$charts_activeUsers,
 		$charts_apRequest,
 		$charts_drive,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index f6fc79fc7..16b20c1a4 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -95,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js';
 import * as ep___channels_timeline from './endpoints/channels/timeline.js';
 import * as ep___channels_unfollow from './endpoints/channels/unfollow.js';
 import * as ep___channels_update from './endpoints/channels/update.js';
+import * as ep___channels_favorite from './endpoints/channels/favorite.js';
+import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
+import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
 import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
 import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
 import * as ep___charts_drive from './endpoints/charts/drive.js';
@@ -422,6 +425,9 @@ const eps = [
 	['channels/timeline', ep___channels_timeline],
 	['channels/unfollow', ep___channels_unfollow],
 	['channels/update', ep___channels_update],
+	['channels/favorite', ep___channels_favorite],
+	['channels/unfavorite', ep___channels_unfavorite],
+	['channels/my-favorites', ep___channels_myFavorites],
 	['charts/active-users', ep___charts_activeUsers],
 	['charts/ap-request', ep___charts_apRequest],
 	['charts/drive', ep___charts_drive],
diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts
new file mode 100644
index 000000000..f52b45ccf
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts
@@ -0,0 +1,61 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: true,
+
+	kind: 'write:channels',
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: '4938f5f3-6167-4c04-9149-6607b7542861',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		channelId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['channelId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.channelsRepository)
+		private channelsRepository: ChannelsRepository,
+
+		@Inject(DI.channelFavoritesRepository)
+		private channelFavoritesRepository: ChannelFavoritesRepository,
+
+		private idService: IdService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const channel = await this.channelsRepository.findOneBy({
+				id: ps.channelId,
+			});
+
+			if (channel == null) {
+				throw new ApiError(meta.errors.noSuchChannel);
+			}
+
+			await this.channelFavoritesRepository.insert({
+				id: this.idService.genId(),
+				createdAt: new Date(),
+				userId: me.id,
+				channelId: channel.id,
+			});
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts
new file mode 100644
index 000000000..60525ed06
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts
@@ -0,0 +1,54 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { ChannelFavoritesRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	tags: ['channels', 'account'],
+
+	requireCredential: true,
+
+	kind: 'read:channels',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Channel',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+	},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.channelFavoritesRepository)
+		private channelFavoritesRepository: ChannelFavoritesRepository,
+
+		private channelEntityService: ChannelEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.channelFavoritesRepository.createQueryBuilder('favorite')
+				.andWhere('favorite.userId = :meId', { meId: me.id })
+				.leftJoinAndSelect('favorite.channel', 'channel');
+
+			const favorites = await query
+				.getMany();
+
+			return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me)));
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts
new file mode 100644
index 000000000..0c3f6c485
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts
@@ -0,0 +1,56 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: true,
+
+	kind: 'write:channels',
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: '353c68dd-131a-476c-aa99-88a345e83668',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		channelId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['channelId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.channelsRepository)
+		private channelsRepository: ChannelsRepository,
+
+		@Inject(DI.channelFavoritesRepository)
+		private channelFavoritesRepository: ChannelFavoritesRepository,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const channel = await this.channelsRepository.findOneBy({
+				id: ps.channelId,
+			});
+
+			if (channel == null) {
+				throw new ApiError(meta.errors.noSuchChannel);
+			}
+
+			await this.channelFavoritesRepository.delete({
+				userId: me.id,
+				channelId: channel.id,
+			});
+		});
+	}
+}
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 76f11faab..0fee2181d 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -16,6 +16,9 @@
 					<Mfm :text="channel.description" :is-note="false" :i="$i"/>
 				</div>
 			</div>
+
+			<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>
 		</div>
 		<div v-if="channel && tab === 'timeline'" class="_gaps">
 			<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
@@ -63,6 +66,7 @@ const props = defineProps<{
 
 let tab = $ref('timeline');
 let channel = $ref(null);
+let favorited = $ref(false);
 const featuredPagination = $computed(() => ({
 	endpoint: 'notes/featured' as const,
 	limit: 10,
@@ -76,6 +80,7 @@ watch(() => props.channelId, async () => {
 	channel = await os.api('channels/show', {
 		channelId: props.channelId,
 	});
+	favorited = channel.isFavorited;
 }, { immediate: true });
 
 function edit() {
@@ -90,6 +95,27 @@ function openPostForm() {
 	});
 }
 
+function favorite() {
+	os.apiWithDialog('channels/favorite', {
+		channelId: channel.id,
+	}).then(() => {
+		favorited = true;
+	});
+}
+
+async function unfavorite() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unfavoriteConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('channels/unfavorite', {
+		channelId: channel.id,
+	}).then(() => {
+		favorited = false;
+	});
+}
+
 const headerActions = $computed(() => {
 	if (channel && channel.userId) {
 		const share = {
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index 3550c7f43..fd1d2d03c 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -2,17 +2,22 @@
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="700">
-		<div v-if="tab === 'featured'" class="grwlizim featured">
+		<div v-if="tab === 'featured'">
 			<MkPagination v-slot="{items}" :pagination="featuredPagination">
 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
 			</MkPagination>
 		</div>
-		<div v-else-if="tab === 'following'" class="grwlizim following">
+		<div v-else-if="tab === 'favorites'">
+			<MkPagination v-slot="{items}" :pagination="favoritesPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'following'">
 			<MkPagination v-slot="{items}" :pagination="followingPagination">
 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
 			</MkPagination>
 		</div>
-		<div v-else-if="tab === 'owned'" class="grwlizim owned">
+		<div v-else-if="tab === 'owned'">
 			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
 			<MkPagination v-slot="{items}" :pagination="ownedPagination">
 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
@@ -39,13 +44,17 @@ const featuredPagination = {
 	endpoint: 'channels/featured' as const,
 	noPaging: true,
 };
+const favoritesPagination = {
+	endpoint: 'channels/my-favorites' as const,
+	limit: 100,
+};
 const followingPagination = {
 	endpoint: 'channels/followed' as const,
-	limit: 5,
+	limit: 10,
 };
 const ownedPagination = {
 	endpoint: 'channels/owned' as const,
-	limit: 5,
+	limit: 10,
 };
 
 function create() {
@@ -62,10 +71,14 @@ const headerTabs = $computed(() => [{
 	key: 'featured',
 	title: i18n.ts._channel.featured,
 	icon: 'ti ti-comet',
+}, {
+	key: 'favorites',
+	title: i18n.ts.favorites,
+	icon: 'ti ti-star',
 }, {
 	key: 'following',
 	title: i18n.ts._channel.following,
-	icon: 'ti ti-heart',
+	icon: 'ti ti-eye',
 }, {
 	key: 'owned',
 	title: i18n.ts._channel.owned,
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index d982a76d0..62b0b4551 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -83,7 +83,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
 }
 
 async function chooseChannel(ev: MouseEvent): Promise<void> {
-	const channels = await os.api('channels/followed', {
+	const channels = await os.api('channels/my-favorites', {
 		limit: 100,
 	});
 	const items = channels.map(channel => ({