summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorChocolate Pie <106949016+chocolate-pie@users.noreply.github.com>2023-05-19 10:06:12 +0900
committerGitHub <noreply@github.com>2023-05-19 10:06:12 +0900
commitdddbc1c894f53d6891ca7760dd9382c19931661a (patch)
treeedc57ec92a68d547c9b31e5aa139dcf61ba5095a /packages
parentperf: MkImgWithBlurhashとMkMediaImageを最適化 (#10782) (diff)
downloadmisskey-dddbc1c894f53d6891ca7760dd9382c19931661a.tar.gz
misskey-dddbc1c894f53d6891ca7760dd9382c19931661a.tar.bz2
misskey-dddbc1c894f53d6891ca7760dd9382c19931661a.zip
feat: 公開リスト (#10842)
* feat: まず公開できるように (misskey-dev/misskey#10447) * feat: 公開したリストのページを作成 (misskey-dev/misskey#10447) * feat: いいねできるように * feat: インポートに対応 * wip * wip * CHANGELOGを編集 * add note * refactor --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1683847157541-UserList.js13
-rw-r--r--packages/backend/migration/1683869758873-UserListFavorites.js19
-rw-r--r--packages/backend/src/core/entities/UserListEntityService.ts1
-rw-r--r--packages/backend/src/di-symbols.ts1
-rw-r--r--packages/backend/src/models/RepositoryModule.ts10
-rw-r--r--packages/backend/src/models/entities/UserList.ts6
-rw-r--r--packages/backend/src/models/entities/UserListFavorite.ts33
-rw-r--r--packages/backend/src/models/index.ts3
-rw-r--r--packages/backend/src/models/json-schema/user-list.ts5
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts12
-rw-r--r--packages/backend/src/server/api/endpoints.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts148
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/favorite.ts70
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/list.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/show.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts63
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/update.ts5
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue2
-rw-r--r--packages/frontend/src/pages/list.vue148
-rw-r--r--packages/frontend/src/pages/my-lists/index.vue1
-rw-r--r--packages/frontend/src/pages/my-lists/list.vue90
-rw-r--r--packages/frontend/src/pages/user/index.vue6
-rw-r--r--packages/frontend/src/pages/user/lists.vue51
-rw-r--r--packages/frontend/src/router.ts4
25 files changed, 725 insertions, 54 deletions
diff --git a/packages/backend/migration/1683847157541-UserList.js b/packages/backend/migration/1683847157541-UserList.js
new file mode 100644
index 0000000000..b50a50eed8
--- /dev/null
+++ b/packages/backend/migration/1683847157541-UserList.js
@@ -0,0 +1,13 @@
+export class UserList1683847157541 {
+ name = 'UserList1683847157541'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user_list" ADD "isPublic" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`CREATE INDEX "IDX_48a00f08598662b9ca540521eb" ON "user_list" ("isPublic") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_48a00f08598662b9ca540521eb"`);
+ await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "isPublic"`);
+ }
+}
diff --git a/packages/backend/migration/1683869758873-UserListFavorites.js b/packages/backend/migration/1683869758873-UserListFavorites.js
new file mode 100644
index 0000000000..ac9c4c42b9
--- /dev/null
+++ b/packages/backend/migration/1683869758873-UserListFavorites.js
@@ -0,0 +1,19 @@
+export class UserListFavorites1683869758873 {
+ name = 'UserListFavorites1683869758873'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "user_list_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_c0974b21e18502a4c8178e09fe6" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_016f613dc4feb807e03e3e7da9" ON "user_list_favorite" ("userId") `);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d6765a8c2a4c17c33f9d7f948b" ON "user_list_favorite" ("userId", "userListId") `);
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_016f613dc4feb807e03e3e7da92" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2"`);
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_016f613dc4feb807e03e3e7da92"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_d6765a8c2a4c17c33f9d7f948b"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_016f613dc4feb807e03e3e7da9"`);
+ await queryRunner.query(`DROP TABLE "user_list_favorite"`);
+ }
+}
diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts
index 2461cb2c12..8628819278 100644
--- a/packages/backend/src/core/entities/UserListEntityService.ts
+++ b/packages/backend/src/core/entities/UserListEntityService.ts
@@ -35,6 +35,7 @@ export class UserListEntityService {
createdAt: userList.createdAt.toISOString(),
name: userList.name,
userIds: users.map(x => x.userId),
+ isPublic: userList.isPublic,
};
}
}
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index c06c7a7159..4a073f102f 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -25,6 +25,7 @@ export const DI = {
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),
+ userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListJoiningsRepository: Symbol('userListJoiningsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'),
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 588c98b58d..4231acc046 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, 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, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js';
+import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, 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, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -112,6 +112,12 @@ const $userListsRepository: Provider = {
inject: [DI.db],
};
+const $userListFavoritesRepository: Provider = {
+ provide: DI.userListFavoritesRepository,
+ useFactory: (db: DataSource) => db.getRepository(UserListFavorite),
+ inject: [DI.db],
+};
+
const $userListJoiningsRepository: Provider = {
provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
@@ -416,6 +422,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
+ $userListFavoritesRepository,
$userListJoiningsRepository,
$userNotePiningsRepository,
$userIpsRepository,
@@ -483,6 +490,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
+ $userListFavoritesRepository,
$userListJoiningsRepository,
$userNotePiningsRepository,
$userIpsRepository,
diff --git a/packages/backend/src/models/entities/UserList.ts b/packages/backend/src/models/entities/UserList.ts
index b8a4b54d4c..94f3dc3cb3 100644
--- a/packages/backend/src/models/entities/UserList.ts
+++ b/packages/backend/src/models/entities/UserList.ts
@@ -19,6 +19,12 @@ export class UserList {
})
public userId: User['id'];
+ @Index()
+ @Column('boolean', {
+ default: false,
+ })
+ public isPublic: boolean;
+
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
diff --git a/packages/backend/src/models/entities/UserListFavorite.ts b/packages/backend/src/models/entities/UserListFavorite.ts
new file mode 100644
index 0000000000..e57abb460a
--- /dev/null
+++ b/packages/backend/src/models/entities/UserListFavorite.ts
@@ -0,0 +1,33 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from '../id.js';
+import { User } from './User.js';
+import { UserList } from './UserList.js';
+
+@Entity()
+@Index(['userId', 'userListId'], { unique: true })
+export class UserListFavorite {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column('timestamp with time zone')
+ public createdAt: Date;
+
+ @Index()
+ @Column(id())
+ public userId: User['id'];
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: User | null;
+
+ @Column(id())
+ public userListId: UserList['id'];
+
+ @ManyToOne(type => UserList, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public userList: UserList | null;
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index b8ba28db9b..4b230ab742 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js';
+import { UserListFavorite } from './entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js';
@@ -117,6 +118,7 @@ export {
UserIp,
UserKeypair,
UserList,
+ UserListFavorite,
UserListJoining,
UserNotePining,
UserPending,
@@ -184,6 +186,7 @@ export type UsersRepository = Repository<User>;
export type UserIpsRepository = Repository<UserIp>;
export type UserKeypairsRepository = Repository<UserKeypair>;
export type UserListsRepository = Repository<UserList>;
+export type UserListFavoritesRepository = Repository<UserListFavorite>;
export type UserListJoiningsRepository = Repository<UserListJoining>;
export type UserNotePiningsRepository = Repository<UserNotePining>;
export type UserPendingsRepository = Repository<UserPending>;
diff --git a/packages/backend/src/models/json-schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts
index 3ba5dc4a8a..1e620516e4 100644
--- a/packages/backend/src/models/json-schema/user-list.ts
+++ b/packages/backend/src/models/json-schema/user-list.ts
@@ -25,5 +25,10 @@ export const packedUserListSchema = {
format: 'id',
},
},
+ isPublic: {
+ type: 'boolean',
+ nullable: false,
+ optional: false,
+ },
},
} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index f3d404e6c9..488979c409 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -57,6 +57,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js';
+import { UserListFavorite } from '@/models/entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js';
@@ -132,6 +133,7 @@ export const entities = [
UserKeypair,
UserPublickey,
UserList,
+ UserListFavorite,
UserListJoining,
UserNotePining,
UserSecurityKey,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index ee1aae5b6c..1e32e9988d 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -321,6 +321,9 @@ import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
+import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
+import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
+import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_reactions from './endpoints/users/reactions.js';
@@ -659,6 +662,9 @@ const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass:
const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default };
const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default };
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
+const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
+const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
+const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default };
@@ -1001,6 +1007,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push,
$users_lists_show,
$users_lists_update,
+ $users_lists_favorite,
+ $users_lists_unfavorite,
+ $users_lists_create_from_public,
$users_notes,
$users_pages,
$users_reactions,
@@ -1335,6 +1344,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push,
$users_lists_show,
$users_lists_update,
+ $users_lists_favorite,
+ $users_lists_unfavorite,
+ $users_lists_create_from_public,
$users_notes,
$users_pages,
$users_reactions,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 09bd7cbff4..7e678a6404 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -320,6 +320,9 @@ import * as ep___users_lists_list from './endpoints/users/lists/list.js';
import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
+import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
+import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
+import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
@@ -656,7 +659,10 @@ const eps = [
['users/lists/pull', ep___users_lists_pull],
['users/lists/push', ep___users_lists_push],
['users/lists/show', ep___users_lists_show],
+ ['users/lists/favorite', ep___users_lists_favorite],
+ ['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update],
+ ['users/lists/create-from-public', ep___users_lists_create_from_public],
['users/notes', ep___users_notes],
['users/pages', ep___users_pages],
['users/reactions', ep___users_reactions],
diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
new file mode 100644
index 0000000000..8591e4ab96
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
@@ -0,0 +1,148 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import type { UserList } from '@/models/entities/UserList.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { RoleService } from '@/core/RoleService.js';
+import { UserListService } from '@/core/UserListService.js';
+
+export const meta = {
+ requireCredential: true,
+ prohibitMoved: true,
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserList',
+ },
+
+ errors: {
+ tooManyUserLists: {
+ message: 'You cannot create user list any more.',
+ code: 'TOO_MANY_USERLISTS',
+ id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f',
+ },
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '9292f798-6175-4f7d-93f4-b6742279667d',
+ },
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f',
+ },
+
+ alreadyAdded: {
+ message: 'That user has already been added to that list.',
+ code: 'ALREADY_ADDED',
+ id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615',
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot push this user because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'a2497f2a-2389-439c-8626-5298540530f4',
+ },
+
+ tooManyUsers: {
+ message: 'You can not push users any more.',
+ code: 'TOO_MANY_USERS',
+ id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ name: { type: 'string', minLength: 1, maxLength: 100 },
+ listId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['name', 'listId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ private userListService: UserListService,
+ private userListEntityService: UserListEntityService,
+ private idService: IdService,
+ private getterService: GetterService,
+ private roleService: RoleService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const list = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ isPublic: true,
+ });
+ if (list === null) throw new ApiError(meta.errors.noSuchList);
+ const currentCount = await this.userListsRepository.countBy({
+ userId: me.id,
+ });
+ if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) {
+ throw new ApiError(meta.errors.tooManyUserLists);
+ }
+
+ const userList = await this.userListsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ userId: me.id,
+ name: ps.name,
+ } as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
+
+ const users = (await this.userListJoiningsRepository.findBy({
+ userListId: ps.listId,
+ })).map(x => x.userId);
+
+ for (const user of users) {
+ const currentUser = await this.getterService.getUser(user).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ if (currentUser.id !== me.id) {
+ const block = await this.blockingsRepository.findOneBy({
+ blockerId: currentUser.id,
+ blockeeId: me.id,
+ });
+ if (block) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+
+ const exist = await this.userListJoiningsRepository.findOneBy({
+ userListId: userList.id,
+ userId: currentUser.id,
+ });
+
+ if (exist) {
+ throw new ApiError(meta.errors.alreadyAdded);
+ }
+
+ try {
+ await this.userListService.push(currentUser, userList, me);
+ } catch (err) {
+ if (err instanceof UserListService.TooManyUsersError) {
+ throw new ApiError(meta.errors.tooManyUsers);
+ }
+ throw err;
+ }
+ }
+ return await this.userListEntityService.pack(userList);
+ });
+ }
+}
+
diff --git a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts
new file mode 100644
index 0000000000..263852fde1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { ApiError } from '@/server/api/error.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ requireCredential: true,
+ errors: {
+ noSuchList: {
+ message: 'No such user list.',
+ code: 'NO_SUCH_USER_LIST',
+ id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe',
+ },
+
+ alreadyFavorited: {
+ message: 'The list has already been favorited.',
+ code: 'ALREADY_FAVORITED',
+ id: '6425bba0-985b-461e-af1b-518070e72081',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ listId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['listId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor (
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListFavoritesRepository)
+ private userListFavoritesRepository: UserListFavoritesRepository,
+ private idService: IdService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const userList = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ isPublic: true,
+ });
+
+ if (userList === null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ const exist = await this.userListFavoritesRepository.findOneBy({
+ userId: me.id,
+ userListId: ps.listId,
+ });
+
+ if (exist !== null) {
+ throw new ApiError(meta.errors.alreadyFavorited);
+ }
+
+ await this.userListFavoritesRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ userId: me.id,
+ userListId: ps.listId,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts
index 2104c4377d..eab29944b2 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/list.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts
@@ -1,13 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
-import type { UserListsRepository } from '@/models/index.js';
+import type { UserListsRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
+import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['lists', 'account'],
- requireCredential: true,
+ requireCredential: false,
kind: 'read:account',
@@ -22,26 +23,58 @@ export const meta = {
ref: 'UserList',
},
},
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e',
+ },
+ remoteUser: {
+ message: 'Not allowed to load the remote user\'s list',
+ code: 'REMOTE_USER_NOT_ALLOWED',
+ id: '53858f1b-3315-4a01-81b7-db9b48d4b79a',
+ },
+ invalidParam: {
+ message: 'Invalid param.',
+ code: 'INVALID_PARAM',
+ id: 'ab36de0e-29e9-48cb-9732-d82f1281620d',
+ },
+ },
} as const;
export const paramDef = {
type: 'object',
- properties: {},
+ properties: {
+ userId: { type: 'string', format: 'misskey:id' },
+ },
required: [],
} as const;
-// eslint-disable-next-line import/no-default-export
-@Injectable()
+@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
- const userLists = await this.userListsRepository.findBy({
+ if (typeof ps.userId !== 'undefined') {
+ const user = await this.usersRepository.findOneBy({ id: ps.userId });
+ if (user === null) throw new ApiError(meta.errors.noSuchUser);
+ if (user.host !== null) throw new ApiError(meta.errors.remoteUser);
+ } else if (me === null) {
+ throw new ApiError(meta.errors.invalidParam);
+ }
+
+ const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? {
userId: me.id,
+ } : {
+ userId: ps.userId,
+ isPublic: true,
});
return await Promise.all(userLists.map(x => this.userListEntityService.pack(x)));
diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts
index 77f9cba808..8077841c8c 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import type { UserListsRepository } from '@/models/index.js';
+import type { UserListsRepository, UserListFavoritesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
@@ -8,7 +8,7 @@ import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'account'],
- requireCredential: true,
+ requireCredential: false,
kind: 'read:account',
@@ -33,31 +33,54 @@ export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
+ forPublic: { type: 'boolean', default: false },
},
required: ['listId'],
} as const;
-// eslint-disable-next-line import/no-default-export
-@Injectable()
+@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
+ @Inject(DI.userListFavoritesRepository)
+ private userListFavoritesRepository: UserListFavoritesRepository,
+
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
+ const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {};
// Fetch the list
- const userList = await this.userListsRepository.findOneBy({
+ const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId,
userId: me.id,
+ } : {
+ id: ps.listId,
+ isPublic: true,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
- return await this.userListEntityService.pack(userList);
+ if (ps.forPublic && userList.isPublic) {
+ additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({
+ userListId: ps.listId,
+ });
+ if (me !== null) {
+ additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({
+ userId: me.id,
+ userListId: ps.listId,
+ }) !== null);
+ } else {
+ additionalProperties.isLiked = false;
+ }
+ }
+ return {
+ ...await this.userListEntityService.pack(userList),
+ ...additionalProperties,
+ };
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts
new file mode 100644
index 0000000000..be8e317816
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts
@@ -0,0 +1,63 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
+import { ApiError } from '@/server/api/error.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ requireCredential: true,
+ errors: {
+ noSuchList: {
+ message: 'No such user list.',
+ code: 'NO_SUCH_USER_LIST',
+ id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b',
+ },
+
+ notFavorited: {
+ message: 'You have not favorited the list.',
+ code: 'ALREADY_FAVORITED',
+ id: '835c4b27-463d-4cfa-969b-a9058678d465',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ listId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['listId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor (
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListFavoritesRepository)
+ private userListFavoritesRepository: UserListFavoritesRepository,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const userList = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ isPublic: true,
+ });
+
+ if (userList === null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ const exist = await this.userListFavoritesRepository.findOneBy({
+ userListId: ps.listId,
+ userId: me.id,
+ });
+
+ if (exist === null) {
+ throw new ApiError(meta.errors.notFavorited);
+ }
+
+ await this.userListFavoritesRepository.delete({ id: exist.id });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts
index 6453d7d980..b0a95a2f28 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/update.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts
@@ -34,8 +34,9 @@ export const paramDef = {
properties: {
listId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
+ isPublic: { type: 'boolean' },
},
- required: ['listId', 'name'],
+ required: ['listId'],
} as const;
// eslint-disable-next-line import/no-default-export
@@ -48,7 +49,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
- // Fetch the list
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
@@ -60,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userListsRepository.update(userList.id, {
name: ps.name,
+ isPublic: ps.isPublic,
});
return await this.userListEntityService.pack(userList.id);
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index b521171b2a..aabebb3abf 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -38,6 +38,8 @@ const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
async function toggleReaction() {
if (!canToggle.value) return;
+ // TODO: その絵文字を使う権限があるかどうか確認
+
const oldReaction = props.note.myReaction;
if (oldReaction) {
const confirm = await os.confirm({
diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue
new file mode 100644
index 0000000000..a09ff854ca
--- /dev/null
+++ b/packages/frontend/src/pages/list.vue
@@ -0,0 +1,148 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MKSpacer v-if="!(typeof error === 'undefined')" :content-max="1200">
+ <div :class="$style.root">
+ <img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p :class="$style.text">
+ <i class="ti ti-alert-triangle"></i>
+ {{ i18n.ts.nothing }}
+ </p>
+ </div>
+ </MKSpacer>
+ <MkSpacer v-else-if="list" :content-max="700" :class="$style.main">
+ <div v-if="list" class="members _margin">
+ <div :class="$style.member_text">{{ i18n.ts.members }}</div>
+ <div class="_gaps_s">
+ <div v-for="user in users" :key="user.id" :class="$style.userItem">
+ <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ </div>
+ </div>
+ </div>
+ <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" as-like primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton>
+ <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" as-like @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton>
+ <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { watch, computed } from 'vue';
+import * as os from '@/os';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkButton from '@/components/MkButton.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+ listId: string;
+}>();
+
+let list = $ref(null);
+let error = $ref();
+let users = $ref([]);
+
+function fetchList(): void {
+ os.api('users/lists/show', {
+ listId: props.listId,
+ forPublic: true,
+ }).then(_list => {
+ list = _list;
+ os.api('users/show', {
+ userIds: list.userIds,
+ }).then(_users => {
+ users = _users;
+ });
+ }).catch(err => {
+ error = err;
+ });
+}
+
+function like() {
+ os.apiWithDialog('users/lists/favorite', {
+ listId: list.id,
+ }).then(() => {
+ list.isLiked = true;
+ list.likedCount++;
+ });
+}
+
+function unlike() {
+ os.apiWithDialog('users/lists/unfavorite', {
+ listId: list.id,
+ }).then(() => {
+ list.isLiked = false;
+ list.likedCount--;
+ });
+}
+
+async function create() {
+ const { canceled, result: name } = await os.inputText({
+ title: i18n.ts.enterListName,
+ });
+ if (canceled) return;
+ await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: list.id });
+}
+
+watch(() => props.listId, fetchList, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+ title: list.name,
+ icon: 'ti ti-list',
+} : null));
+</script>
+<style lang="scss" module>
+.main {
+ min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+}
+
+.userItem {
+ display: flex;
+}
+
+.userItemBody {
+ flex: 1;
+ min-width: 0;
+ margin-right: 8px;
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+.member_text {
+ margin: 5px;
+}
+
+.root {
+ padding: 32px;
+ text-align: center;
+ align-items: center;
+}
+
+.text {
+ margin: 0 0 8px 0;
+}
+
+.img {
+ vertical-align: bottom;
+ width: 128px;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+}
+
+.button {
+ margin-right: 10px;
+}
+
+.import {
+ margin-right: 4px;
+}
+</style>
diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue
index 47437f3e57..6068e375ea 100644
--- a/packages/frontend/src/pages/my-lists/index.vue
+++ b/packages/frontend/src/pages/my-lists/index.vue
@@ -70,6 +70,7 @@ definePageMetadata({
padding: 16px;
border: solid 1px var(--divider);
border-radius: 6px;
+ margin-bottom: 8px;
&:hover {
border: solid 1px var(--accent);
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index 86201e8e0c..dd431e8dc0 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -1,35 +1,43 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :class="$style.main">
- <div v-if="list" class="members _margin">
- <div class="">{{ i18n.ts.members }}</div>
- <div class="_gaps_s">
- <div v-for="user in users" :key="user.id" :class="$style.userItem">
- <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
- <MkUserCardMini :user="user"/>
- </MkA>
- <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
+ <MkSpacer :contentMax="700" :class="$style.main">
+ <div v-if="list" class="_gaps">
+ <MkFolder>
+ <template #label>{{ i18n.ts.settings }}</template>
+
+ <div class="_gaps">
+ <MkInput v-model="name">
+ <template #label>{{ i18n.ts.name }}</template>
+ </MkInput>
+ <MkSwitch v-model="isPublic">{{ i18n.ts.public }}</MkSwitch>
+ <div class="_buttons">
+ <MkButton rounded primary @click="updateSettings">{{ i18n.ts.save }}</MkButton>
+ <MkButton rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
+ </div>
</div>
- </div>
- </div>
- </MkSpacer>
- <template #footer>
- <div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
- <div class="_buttons">
- <MkButton inline rounded primary @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
- <MkButton inline rounded @click="renameList()">{{ i18n.ts.rename }}</MkButton>
- <MkButton inline rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
+ </MkFolder>
+
+ <MkFolder defaultOpen>
+ <template #label>{{ i18n.ts.members }}</template>
+
+ <div class="_gaps_s">
+ <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
+ <div v-for="user in users" :key="user.id" :class="$style.userItem">
+ <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
+ </div>
</div>
- </MkSpacer>
+ </MkFolder>
</div>
- </template>
+ </MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { computed, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { mainRouter } from '@/router';
@@ -37,6 +45,9 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import { userPage } from '@/filters/user';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInput from '@/components/MkInput.vue';
import { userListsCache } from '@/cache';
const props = defineProps<{
@@ -45,12 +56,17 @@ const props = defineProps<{
let list = $ref(null);
let users = $ref([]);
+const isPublic = ref(false);
+const name = ref('');
function fetchList() {
os.api('users/lists/show', {
listId: props.listId,
}).then(_list => {
list = _list;
+ name.value = list.name;
+ isPublic.value = list.isPublic;
+
os.api('users/show', {
userIds: list.userIds,
}).then(_users => {
@@ -86,23 +102,6 @@ async function removeUser(user, ev) {
}], ev.currentTarget ?? ev.target);
}
-async function renameList() {
- const { canceled, result: name } = await os.inputText({
- title: i18n.ts.enterListName,
- default: list.name,
- });
- if (canceled) return;
-
- await os.api('users/lists/update', {
- listId: list.id,
- name: name,
- });
-
- userListsCache.delete();
-
- list.name = name;
-}
-
async function deleteList() {
const { canceled } = await os.confirm({
type: 'warning',
@@ -117,6 +116,19 @@ async function deleteList() {
mainRouter.push('/my/lists');
}
+async function updateSettings() {
+ await os.apiWithDialog('users/lists/update', {
+ listId: list.id,
+ name: name.value,
+ isPublic: isPublic.value,
+ });
+
+ userListsCache.delete();
+
+ list.name = name.value;
+ list.isPublic = isPublic.value;
+}
+
watch(() => props.listId, fetchList, { immediate: true });
const headerActions = $computed(() => []);
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index 03a226cc09..07f7d30f0c 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -10,6 +10,7 @@
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/>
+ <XLists v-else-if="tab === 'lists'" :user="user"/>
<XPages v-else-if="tab === 'pages'" :user="user"/>
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
</div>
@@ -36,6 +37,7 @@ const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
const XClips = defineAsyncComponent(() => import('./clips.vue'));
+const XLists = defineAsyncComponent(() => import('./lists.vue'));
const XPages = defineAsyncComponent(() => import('./pages.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
@@ -91,6 +93,10 @@ const headerTabs = $computed(() => user ? [{
title: i18n.ts.clips,
icon: 'ti ti-paperclip',
}, {
+ key: 'lists',
+ title: i18n.ts.lists,
+ icon: 'ti ti-list',
+}, {
key: 'pages',
title: i18n.ts.pages,
icon: 'ti ti-news',
diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue
new file mode 100644
index 0000000000..78f03d2b38
--- /dev/null
+++ b/packages/frontend/src/pages/user/lists.vue
@@ -0,0 +1,51 @@
+<template>
+<MkStickyContainer>
+ <MkSpacer :contentMax="700">
+ <div>
+ <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists">
+ <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/list/${ list.id }`">
+ <div>{{ list.name }}</div>
+ <MkAvatars :userIds="list.userIds"/>
+ </MkA>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import {} from 'vue';
+import * as misskey from 'misskey-js';
+import MkPagination from '@/components/MkPagination.vue';
+import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
+import MkSpacer from '@/components/global/MkSpacer.vue';
+import MkAvatars from '@/components/MkAvatars.vue';
+
+const props = defineProps<{
+ user: misskey.entities.UserDetailed;
+}>();
+
+const pagination = {
+ endpoint: 'users/lists/list' as const,
+ noPaging: true,
+ limit: 10,
+ params: {
+ userId: props.user.id,
+ },
+};
+</script>
+
+<style lang="scss" module>
+.list {
+ display: block;
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+ margin-bottom: 8px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+}
+</style>
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index add4bd9217..6b11137d79 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -31,6 +31,10 @@ export const routes = [{
path: '/notes/:noteId',
component: page(() => import('./pages/note.vue')),
}, {
+ name: 'list',
+ path: '/list/:listId',
+ component: page(() => import('./pages/list.vue')),
+}, {
path: '/clips/:clipId',
component: page(() => import('./pages/clip.vue')),
}, {