Compare commits
8 Commits
13.13.0-be
...
13.13.0-be
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0c0ae6ff90 | ||
![]() |
d63b943116 | ||
![]() |
dddbc1c894 | ||
![]() |
f68c743f39 | ||
![]() |
59255e11b8 | ||
![]() |
3804c6e7ad | ||
![]() |
527a13b77d | ||
![]() |
a3423bad60 |
@@ -17,10 +17,14 @@
|
|||||||
### General
|
### General
|
||||||
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
|
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
|
||||||
- カスタム絵文字ごとに連合するかどうか設定できるように
|
- カスタム絵文字ごとに連合するかどうか設定できるように
|
||||||
|
- カスタム絵文字ごとにセンシティブフラグを設定できるように
|
||||||
|
- センシティブなカスタム絵文字のリアクションを受け入れない設定が可能に
|
||||||
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
|
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
|
||||||
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります
|
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります
|
||||||
|
- リストを公開できるようになりました
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
- リアクションの取り消し/変更時に確認ダイアログを出すように
|
||||||
- 開発者モードを追加
|
- 開発者モードを追加
|
||||||
- AiScriptを0.13.3に更新
|
- AiScriptを0.13.3に更新
|
||||||
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
|
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
|
||||||
@@ -100,6 +104,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
|||||||
* 画像が全て隠れた状態で表示されるようになります
|
* 画像が全て隠れた状態で表示されるようになります
|
||||||
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
|
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
|
||||||
- モデレーターはノートに添付された画像上から直接NSFW設定できるように
|
- モデレーターはノートに添付された画像上から直接NSFW設定できるように
|
||||||
|
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
|
||||||
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
|
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
|
||||||
- 新しい実績を追加
|
- 新しい実績を追加
|
||||||
- AiScriptを0.13.2に更新
|
- AiScriptを0.13.2に更新
|
||||||
|
@@ -990,7 +990,9 @@ postToTheChannel: "チャンネルに投稿"
|
|||||||
cannotBeChangedLater: "後から変更できません。"
|
cannotBeChangedLater: "後から変更できません。"
|
||||||
reactionAcceptance: "リアクションの受け入れ"
|
reactionAcceptance: "リアクションの受け入れ"
|
||||||
likeOnly: "いいねのみ"
|
likeOnly: "いいねのみ"
|
||||||
likeOnlyForRemote: "リモートからはいいねのみ"
|
likeOnlyForRemote: "全て (リモートはいいねのみ)"
|
||||||
|
nonSensitiveOnly: "非センシティブのみ"
|
||||||
|
nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)"
|
||||||
rolesAssignedToMe: "自分に割り当てられたロール"
|
rolesAssignedToMe: "自分に割り当てられたロール"
|
||||||
resetPasswordConfirm: "パスワードリセットしますか?"
|
resetPasswordConfirm: "パスワードリセットしますか?"
|
||||||
sensitiveWords: "センシティブワード"
|
sensitiveWords: "センシティブワード"
|
||||||
@@ -1053,6 +1055,8 @@ update: "更新"
|
|||||||
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
|
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
|
||||||
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
|
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
|
||||||
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールである必要があります。"
|
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールである必要があります。"
|
||||||
|
cancelReactionConfirm: "リアクションを取り消しますか?"
|
||||||
|
changeReactionConfirm: "リアクションを変更しますか?"
|
||||||
|
|
||||||
_initialAccountSetting:
|
_initialAccountSetting:
|
||||||
accountCreated: "アカウントの作成が完了しました!"
|
accountCreated: "アカウントの作成が完了しました!"
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "13.13.0-beta.1",
|
"version": "13.13.0-beta.2",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
13
packages/backend/migration/1683847157541-UserList.js
Normal file
13
packages/backend/migration/1683847157541-UserList.js
Normal file
@@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
@@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
@@ -106,7 +106,7 @@ export class ReactionService {
|
|||||||
|
|
||||||
let reaction = _reaction ?? FALLBACK;
|
let reaction = _reaction ?? FALLBACK;
|
||||||
|
|
||||||
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) {
|
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
|
||||||
reaction = '❤️';
|
reaction = '❤️';
|
||||||
} else if (_reaction) {
|
} else if (_reaction) {
|
||||||
const custom = reaction.match(isCustomEmojiRegexp);
|
const custom = reaction.match(isCustomEmojiRegexp);
|
||||||
@@ -124,6 +124,11 @@ export class ReactionService {
|
|||||||
if (emoji) {
|
if (emoji) {
|
||||||
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
|
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
|
||||||
reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
|
reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
|
||||||
|
|
||||||
|
// センシティブ
|
||||||
|
if ((note.reactionAcceptance === 'nonSensitiveOnly') && emoji.isSensitive) {
|
||||||
|
reaction = FALLBACK;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// リアクションとして使う権限がない
|
// リアクションとして使う権限がない
|
||||||
reaction = FALLBACK;
|
reaction = FALLBACK;
|
||||||
|
@@ -35,6 +35,7 @@ export class UserListEntityService {
|
|||||||
createdAt: userList.createdAt.toISOString(),
|
createdAt: userList.createdAt.toISOString(),
|
||||||
name: userList.name,
|
name: userList.name,
|
||||||
userIds: users.map(x => x.userId),
|
userIds: users.map(x => x.userId),
|
||||||
|
isPublic: userList.isPublic,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -25,6 +25,7 @@ export const DI = {
|
|||||||
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
|
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
|
||||||
userPublickeysRepository: Symbol('userPublickeysRepository'),
|
userPublickeysRepository: Symbol('userPublickeysRepository'),
|
||||||
userListsRepository: Symbol('userListsRepository'),
|
userListsRepository: Symbol('userListsRepository'),
|
||||||
|
userListFavoritesRepository: Symbol('userListFavoritesRepository'),
|
||||||
userListJoiningsRepository: Symbol('userListJoiningsRepository'),
|
userListJoiningsRepository: Symbol('userListJoiningsRepository'),
|
||||||
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
|
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
|
||||||
userIpsRepository: Symbol('userIpsRepository'),
|
userIpsRepository: Symbol('userIpsRepository'),
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
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 { DataSource } from 'typeorm';
|
||||||
import type { Provider } from '@nestjs/common';
|
import type { Provider } from '@nestjs/common';
|
||||||
|
|
||||||
@@ -112,6 +112,12 @@ const $userListsRepository: Provider = {
|
|||||||
inject: [DI.db],
|
inject: [DI.db],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $userListFavoritesRepository: Provider = {
|
||||||
|
provide: DI.userListFavoritesRepository,
|
||||||
|
useFactory: (db: DataSource) => db.getRepository(UserListFavorite),
|
||||||
|
inject: [DI.db],
|
||||||
|
};
|
||||||
|
|
||||||
const $userListJoiningsRepository: Provider = {
|
const $userListJoiningsRepository: Provider = {
|
||||||
provide: DI.userListJoiningsRepository,
|
provide: DI.userListJoiningsRepository,
|
||||||
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
|
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
|
||||||
@@ -416,6 +422,7 @@ const $userMemosRepository: Provider = {
|
|||||||
$userSecurityKeysRepository,
|
$userSecurityKeysRepository,
|
||||||
$userPublickeysRepository,
|
$userPublickeysRepository,
|
||||||
$userListsRepository,
|
$userListsRepository,
|
||||||
|
$userListFavoritesRepository,
|
||||||
$userListJoiningsRepository,
|
$userListJoiningsRepository,
|
||||||
$userNotePiningsRepository,
|
$userNotePiningsRepository,
|
||||||
$userIpsRepository,
|
$userIpsRepository,
|
||||||
@@ -483,6 +490,7 @@ const $userMemosRepository: Provider = {
|
|||||||
$userSecurityKeysRepository,
|
$userSecurityKeysRepository,
|
||||||
$userPublickeysRepository,
|
$userPublickeysRepository,
|
||||||
$userListsRepository,
|
$userListsRepository,
|
||||||
|
$userListFavoritesRepository,
|
||||||
$userListJoiningsRepository,
|
$userListJoiningsRepository,
|
||||||
$userNotePiningsRepository,
|
$userNotePiningsRepository,
|
||||||
$userIpsRepository,
|
$userIpsRepository,
|
||||||
|
@@ -90,7 +90,7 @@ export class Note {
|
|||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 64, nullable: true,
|
length: 64, nullable: true,
|
||||||
})
|
})
|
||||||
public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null;
|
public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
|
||||||
|
|
||||||
@Column('smallint', {
|
@Column('smallint', {
|
||||||
default: 0,
|
default: 0,
|
||||||
|
@@ -19,6 +19,12 @@ export class UserList {
|
|||||||
})
|
})
|
||||||
public userId: User['id'];
|
public userId: User['id'];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('boolean', {
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
public isPublic: boolean;
|
||||||
|
|
||||||
@ManyToOne(type => User, {
|
@ManyToOne(type => User, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
|
33
packages/backend/src/models/entities/UserListFavorite.ts
Normal file
33
packages/backend/src/models/entities/UserListFavorite.ts
Normal file
@@ -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;
|
||||||
|
}
|
@@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js';
|
|||||||
import { UserIp } from '@/models/entities/UserIp.js';
|
import { UserIp } from '@/models/entities/UserIp.js';
|
||||||
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||||
import { UserList } from '@/models/entities/UserList.js';
|
import { UserList } from '@/models/entities/UserList.js';
|
||||||
|
import { UserListFavorite } from './entities/UserListFavorite.js';
|
||||||
import { UserListJoining } from '@/models/entities/UserListJoining.js';
|
import { UserListJoining } from '@/models/entities/UserListJoining.js';
|
||||||
import { UserNotePining } from '@/models/entities/UserNotePining.js';
|
import { UserNotePining } from '@/models/entities/UserNotePining.js';
|
||||||
import { UserPending } from '@/models/entities/UserPending.js';
|
import { UserPending } from '@/models/entities/UserPending.js';
|
||||||
@@ -117,6 +118,7 @@ export {
|
|||||||
UserIp,
|
UserIp,
|
||||||
UserKeypair,
|
UserKeypair,
|
||||||
UserList,
|
UserList,
|
||||||
|
UserListFavorite,
|
||||||
UserListJoining,
|
UserListJoining,
|
||||||
UserNotePining,
|
UserNotePining,
|
||||||
UserPending,
|
UserPending,
|
||||||
@@ -184,6 +186,7 @@ export type UsersRepository = Repository<User>;
|
|||||||
export type UserIpsRepository = Repository<UserIp>;
|
export type UserIpsRepository = Repository<UserIp>;
|
||||||
export type UserKeypairsRepository = Repository<UserKeypair>;
|
export type UserKeypairsRepository = Repository<UserKeypair>;
|
||||||
export type UserListsRepository = Repository<UserList>;
|
export type UserListsRepository = Repository<UserList>;
|
||||||
|
export type UserListFavoritesRepository = Repository<UserListFavorite>;
|
||||||
export type UserListJoiningsRepository = Repository<UserListJoining>;
|
export type UserListJoiningsRepository = Repository<UserListJoining>;
|
||||||
export type UserNotePiningsRepository = Repository<UserNotePining>;
|
export type UserNotePiningsRepository = Repository<UserNotePining>;
|
||||||
export type UserPendingsRepository = Repository<UserPending>;
|
export type UserPendingsRepository = Repository<UserPending>;
|
||||||
|
@@ -25,5 +25,10 @@ export const packedUserListSchema = {
|
|||||||
format: 'id',
|
format: 'id',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
isPublic: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: false,
|
||||||
|
optional: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@@ -57,6 +57,7 @@ import { User } from '@/models/entities/User.js';
|
|||||||
import { UserIp } from '@/models/entities/UserIp.js';
|
import { UserIp } from '@/models/entities/UserIp.js';
|
||||||
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||||
import { UserList } from '@/models/entities/UserList.js';
|
import { UserList } from '@/models/entities/UserList.js';
|
||||||
|
import { UserListFavorite } from '@/models/entities/UserListFavorite.js';
|
||||||
import { UserListJoining } from '@/models/entities/UserListJoining.js';
|
import { UserListJoining } from '@/models/entities/UserListJoining.js';
|
||||||
import { UserNotePining } from '@/models/entities/UserNotePining.js';
|
import { UserNotePining } from '@/models/entities/UserNotePining.js';
|
||||||
import { UserPending } from '@/models/entities/UserPending.js';
|
import { UserPending } from '@/models/entities/UserPending.js';
|
||||||
@@ -132,6 +133,7 @@ export const entities = [
|
|||||||
UserKeypair,
|
UserKeypair,
|
||||||
UserPublickey,
|
UserPublickey,
|
||||||
UserList,
|
UserList,
|
||||||
|
UserListFavorite,
|
||||||
UserListJoining,
|
UserListJoining,
|
||||||
UserNotePining,
|
UserNotePining,
|
||||||
UserSecurityKey,
|
UserSecurityKey,
|
||||||
|
@@ -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_push from './endpoints/users/lists/push.js';
|
||||||
import * as ep___users_lists_show from './endpoints/users/lists/show.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_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_notes from './endpoints/users/notes.js';
|
||||||
import * as ep___users_pages from './endpoints/users/pages.js';
|
import * as ep___users_pages from './endpoints/users/pages.js';
|
||||||
import * as ep___users_reactions from './endpoints/users/reactions.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_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_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_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_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_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
|
||||||
const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.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_push,
|
||||||
$users_lists_show,
|
$users_lists_show,
|
||||||
$users_lists_update,
|
$users_lists_update,
|
||||||
|
$users_lists_favorite,
|
||||||
|
$users_lists_unfavorite,
|
||||||
|
$users_lists_create_from_public,
|
||||||
$users_notes,
|
$users_notes,
|
||||||
$users_pages,
|
$users_pages,
|
||||||
$users_reactions,
|
$users_reactions,
|
||||||
@@ -1335,6 +1344,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||||||
$users_lists_push,
|
$users_lists_push,
|
||||||
$users_lists_show,
|
$users_lists_show,
|
||||||
$users_lists_update,
|
$users_lists_update,
|
||||||
|
$users_lists_favorite,
|
||||||
|
$users_lists_unfavorite,
|
||||||
|
$users_lists_create_from_public,
|
||||||
$users_notes,
|
$users_notes,
|
||||||
$users_pages,
|
$users_pages,
|
||||||
$users_reactions,
|
$users_reactions,
|
||||||
|
@@ -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_pull from './endpoints/users/lists/pull.js';
|
||||||
import * as ep___users_lists_push from './endpoints/users/lists/push.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_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_lists_update from './endpoints/users/lists/update.js';
|
||||||
import * as ep___users_notes from './endpoints/users/notes.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_pages from './endpoints/users/pages.js';
|
||||||
@@ -656,7 +659,10 @@ const eps = [
|
|||||||
['users/lists/pull', ep___users_lists_pull],
|
['users/lists/pull', ep___users_lists_pull],
|
||||||
['users/lists/push', ep___users_lists_push],
|
['users/lists/push', ep___users_lists_push],
|
||||||
['users/lists/show', ep___users_lists_show],
|
['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/update', ep___users_lists_update],
|
||||||
|
['users/lists/create-from-public', ep___users_lists_create_from_public],
|
||||||
['users/notes', ep___users_notes],
|
['users/notes', ep___users_notes],
|
||||||
['users/pages', ep___users_pages],
|
['users/pages', ep___users_pages],
|
||||||
['users/reactions', ep___users_reactions],
|
['users/reactions', ep___users_reactions],
|
||||||
|
@@ -99,7 +99,7 @@ export const paramDef = {
|
|||||||
} },
|
} },
|
||||||
cw: { type: 'string', nullable: true, maxLength: 100 },
|
cw: { type: 'string', nullable: true, maxLength: 100 },
|
||||||
localOnly: { type: 'boolean', default: false },
|
localOnly: { type: 'boolean', default: false },
|
||||||
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null },
|
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
|
||||||
noExtractMentions: { type: 'boolean', default: false },
|
noExtractMentions: { type: 'boolean', default: false },
|
||||||
noExtractHashtags: { type: 'boolean', default: false },
|
noExtractHashtags: { type: 'boolean', default: false },
|
||||||
noExtractEmojis: { type: 'boolean', default: false },
|
noExtractEmojis: { type: 'boolean', default: false },
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,14 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
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 { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
|
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
|
||||||
|
import { ApiError } from '@/server/api/error.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['lists', 'account'],
|
tags: ['lists', 'account'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: false,
|
||||||
|
|
||||||
kind: 'read:account',
|
kind: 'read:account',
|
||||||
|
|
||||||
@@ -22,26 +23,58 @@ export const meta = {
|
|||||||
ref: 'UserList',
|
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;
|
} as const;
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {
|
||||||
|
userId: { type: 'string', format: 'misskey:id' },
|
||||||
|
},
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
@Injectable() // eslint-disable-next-line import/no-default-export
|
||||||
@Injectable()
|
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.userListsRepository)
|
@Inject(DI.userListsRepository)
|
||||||
private userListsRepository: UserListsRepository,
|
private userListsRepository: UserListsRepository,
|
||||||
|
|
||||||
private userListEntityService: UserListEntityService,
|
private userListEntityService: UserListEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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: me.id,
|
||||||
|
} : {
|
||||||
|
userId: ps.userId,
|
||||||
|
isPublic: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await Promise.all(userLists.map(x => this.userListEntityService.pack(x)));
|
return await Promise.all(userLists.map(x => this.userListEntityService.pack(x)));
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
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 { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
|
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
@@ -8,7 +8,7 @@ import { ApiError } from '../../../error.js';
|
|||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['lists', 'account'],
|
tags: ['lists', 'account'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: false,
|
||||||
|
|
||||||
kind: 'read:account',
|
kind: 'read:account',
|
||||||
|
|
||||||
@@ -33,31 +33,54 @@ export const paramDef = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
listId: { type: 'string', format: 'misskey:id' },
|
listId: { type: 'string', format: 'misskey:id' },
|
||||||
|
forPublic: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['listId'],
|
required: ['listId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
@Injectable() // eslint-disable-next-line import/no-default-export
|
||||||
@Injectable()
|
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.userListsRepository)
|
@Inject(DI.userListsRepository)
|
||||||
private userListsRepository: UserListsRepository,
|
private userListsRepository: UserListsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userListFavoritesRepository)
|
||||||
|
private userListFavoritesRepository: UserListFavoritesRepository,
|
||||||
|
|
||||||
private userListEntityService: UserListEntityService,
|
private userListEntityService: UserListEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {};
|
||||||
// Fetch the list
|
// Fetch the list
|
||||||
const userList = await this.userListsRepository.findOneBy({
|
const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
|
||||||
id: ps.listId,
|
id: ps.listId,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
|
} : {
|
||||||
|
id: ps.listId,
|
||||||
|
isPublic: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userList == null) {
|
if (userList == null) {
|
||||||
throw new ApiError(meta.errors.noSuchList);
|
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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -34,8 +34,9 @@ export const paramDef = {
|
|||||||
properties: {
|
properties: {
|
||||||
listId: { type: 'string', format: 'misskey:id' },
|
listId: { type: 'string', format: 'misskey:id' },
|
||||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
|
isPublic: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
required: ['listId', 'name'],
|
required: ['listId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
@@ -48,7 +49,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
private userListEntityService: UserListEntityService,
|
private userListEntityService: UserListEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
// Fetch the list
|
|
||||||
const userList = await this.userListsRepository.findOneBy({
|
const userList = await this.userListsRepository.findOneBy({
|
||||||
id: ps.listId,
|
id: ps.listId,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
@@ -60,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
|
|
||||||
await this.userListsRepository.update(userList.id, {
|
await this.userListsRepository.update(userList.id, {
|
||||||
name: ps.name,
|
name: ps.name,
|
||||||
|
isPublic: ps.isPublic,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.userListEntityService.pack(userList.id);
|
return await this.userListEntityService.pack(userList.id);
|
||||||
|
@@ -25,9 +25,9 @@
|
|||||||
"@vue-macros/reactivity-transform": "0.3.7",
|
"@vue-macros/reactivity-transform": "0.3.7",
|
||||||
"@vue/compiler-sfc": "3.3.2",
|
"@vue/compiler-sfc": "3.3.2",
|
||||||
"autosize": "6.0.1",
|
"autosize": "6.0.1",
|
||||||
"blurhash": "2.0.5",
|
|
||||||
"broadcast-channel": "4.20.2",
|
"broadcast-channel": "4.20.2",
|
||||||
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||||
|
"buraha": "github:misskey-dev/buraha",
|
||||||
"canvas-confetti": "1.6.0",
|
"canvas-confetti": "1.6.0",
|
||||||
"chart.js": "4.3.0",
|
"chart.js": "4.3.0",
|
||||||
"chartjs-adapter-date-fns": "3.0.0",
|
"chartjs-adapter-date-fns": "3.0.0",
|
||||||
|
@@ -5,12 +5,9 @@
|
|||||||
<ImgWithBlurhash
|
<ImgWithBlurhash
|
||||||
class="img layered"
|
class="img layered"
|
||||||
:transition="safe ? null : {
|
:transition="safe ? null : {
|
||||||
enterActiveClass: $style.transition_toggle_enterActive,
|
duration: 500,
|
||||||
leaveActiveClass: $style.transition_toggle_leaveActive,
|
leaveActiveClass: $style.transition_toggle_leaveActive,
|
||||||
enterFromClass: $style.transition_toggle_enterFrom,
|
|
||||||
leaveToClass: $style.transition_toggle_leaveTo,
|
leaveToClass: $style.transition_toggle_leaveTo,
|
||||||
enterToClass: $style.transition_toggle_enterTo,
|
|
||||||
leaveFromClass: $style.transition_toggle_leaveFrom,
|
|
||||||
}"
|
}"
|
||||||
:src="post.files[0].thumbnailUrl"
|
:src="post.files[0].thumbnailUrl"
|
||||||
:hash="post.files[0].blurhash"
|
:hash="post.files[0].blurhash"
|
||||||
@@ -53,24 +50,16 @@ function leaveHover(): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.transition_toggle_enterActive,
|
|
||||||
.transition_toggle_leaveActive {
|
.transition_toggle_leaveActive {
|
||||||
transition: opacity 0.5s;
|
transition: opacity .5s;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition_toggle_enterFrom,
|
|
||||||
.transition_toggle_leaveTo {
|
.transition_toggle_leaveTo {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition_toggle_enterTo,
|
|
||||||
.transition_toggle_leaveFrom {
|
|
||||||
transition: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@@ -1,30 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
|
<div ref="root" :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
|
||||||
<img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/>
|
<TransitionGroup
|
||||||
<Transition
|
:duration="defaultStore.state.animation && props.transition?.duration || undefined"
|
||||||
mode="in-out"
|
:enter-active-class="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
|
||||||
:enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined"
|
:leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_leaveActive']) || undefined"
|
||||||
:leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined"
|
|
||||||
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
|
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
|
||||||
:leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
|
:leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
|
||||||
:enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined"
|
:enter-to-class="defaultStore.state.animation && props.transition?.enterToClass || undefined"
|
||||||
:leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined"
|
:leave-from-class="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
|
||||||
>
|
>
|
||||||
<canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/>
|
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
|
||||||
<img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/>
|
<img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
|
||||||
</Transition>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts">
|
||||||
import { onMounted, shallowRef, useCssModule, watch } from 'vue';
|
import DrawBlurhash from '@/workers/draw-blurhash?worker';
|
||||||
import { decode } from 'blurhash';
|
import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||||
import { defaultStore } from '@/store';
|
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch';
|
||||||
|
import { $ref } from 'vue/macros';
|
||||||
|
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
|
||||||
|
|
||||||
|
const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
|
||||||
|
const testWorker = new TestWebGL2();
|
||||||
|
testWorker.addEventListener('message', event => {
|
||||||
|
if (event.data.result) {
|
||||||
|
const workers = new WorkerMultiDispatch(
|
||||||
|
() => new DrawBlurhash(),
|
||||||
|
Math.min(navigator.hardwareConcurrency - 1, 4),
|
||||||
|
);
|
||||||
|
resolve(workers);
|
||||||
|
if (_DEV_) console.log('WebGL2 in worker is supported!');
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
if (_DEV_) console.log('WebGL2 in worker is not supported...');
|
||||||
|
}
|
||||||
|
testWorker.terminate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, shallowRef, useCssModule, watch } from 'vue';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { render } from 'buraha';
|
||||||
|
import { defaultStore } from '@/store';
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
transition?: {
|
transition?: {
|
||||||
|
duration?: number | { enter: number; leave: number; };
|
||||||
enterActiveClass?: string;
|
enterActiveClass?: string;
|
||||||
leaveActiveClass?: string;
|
leaveActiveClass?: string;
|
||||||
enterFromClass?: string;
|
enterFromClass?: string;
|
||||||
@@ -51,67 +77,141 @@ const props = withDefaults(defineProps<{
|
|||||||
forceBlurhash: false,
|
forceBlurhash: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const viewId = uuid();
|
||||||
const canvas = shallowRef<HTMLCanvasElement>();
|
const canvas = shallowRef<HTMLCanvasElement>();
|
||||||
|
const root = shallowRef<HTMLDivElement>();
|
||||||
|
const img = shallowRef<HTMLImageElement>();
|
||||||
let loaded = $ref(false);
|
let loaded = $ref(false);
|
||||||
let width = $ref(props.width);
|
let canvasWidth = $ref(64);
|
||||||
let height = $ref(props.height);
|
let canvasHeight = $ref(64);
|
||||||
|
let imgWidth = $ref(props.width);
|
||||||
|
let imgHeight = $ref(props.height);
|
||||||
|
let bitmapTmp = $ref<CanvasImageSource | undefined>();
|
||||||
|
const hide = computed(() => !loaded || props.forceBlurhash);
|
||||||
|
|
||||||
function onLoad() {
|
function waitForDecode() {
|
||||||
|
if (props.src != null && props.src !== '') {
|
||||||
|
nextTick()
|
||||||
|
.then(() => img.value?.decode())
|
||||||
|
.then(() => {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
|
}, error => {
|
||||||
|
console.error('Error occured during decoding image', img.value, error);
|
||||||
|
throw Error(error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loaded = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([() => props.width, () => props.height], () => {
|
watch([() => props.width, () => props.height, root], () => {
|
||||||
const ratio = props.width / props.height;
|
const ratio = props.width / props.height;
|
||||||
if (ratio > 1) {
|
if (ratio > 1) {
|
||||||
width = Math.round(64 * ratio);
|
canvasWidth = Math.round(64 * ratio);
|
||||||
height = 64;
|
canvasHeight = 64;
|
||||||
} else {
|
} else {
|
||||||
width = 64;
|
canvasWidth = 64;
|
||||||
height = Math.round(64 / ratio);
|
canvasHeight = Math.round(64 / ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientWidth = root.value?.clientWidth ?? 300;
|
||||||
|
imgWidth = clientWidth;
|
||||||
|
imgHeight = Math.round(clientWidth / ratio);
|
||||||
}, {
|
}, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function draw() {
|
function drawImage(bitmap: CanvasImageSource) {
|
||||||
if (props.hash == null || !canvas.value) return;
|
// canvasがない(mountedされていない)場合はTmpに保存しておく
|
||||||
const pixels = decode(props.hash, width, height);
|
if (!canvas.value) {
|
||||||
|
bitmapTmp = bitmap;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasがあれば描画する
|
||||||
|
bitmapTmp = undefined;
|
||||||
const ctx = canvas.value.getContext('2d');
|
const ctx = canvas.value.getContext('2d');
|
||||||
const imageData = ctx!.createImageData(width, height);
|
if (!ctx) return;
|
||||||
imageData.data.set(pixels);
|
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
|
||||||
ctx!.putImageData(imageData, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([() => props.hash, canvas], () => {
|
async function draw() {
|
||||||
|
if (!canvas.value || props.hash == null) return;
|
||||||
|
|
||||||
|
const ctx = canvas.value.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// avgColorでお茶をにごす
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
|
||||||
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
|
const workers = await workerPromise;
|
||||||
|
if (workers) {
|
||||||
|
workers.postMessage(
|
||||||
|
{
|
||||||
|
id: viewId,
|
||||||
|
hash: props.hash,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const work = document.createElement('canvas');
|
||||||
|
work.width = canvasWidth;
|
||||||
|
work.height = canvasHeight;
|
||||||
|
render(props.hash, work);
|
||||||
|
ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error occured during drawing blurhash', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerOnMessage(event: MessageEvent) {
|
||||||
|
if (event.data.id !== viewId) return;
|
||||||
|
drawImage(event.data.bitmap as ImageBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPromise.then(worker => {
|
||||||
|
if (worker) {
|
||||||
|
worker.addListener(workerOnMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
waitForDecode();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.hash, () => {
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
draw();
|
// drawImageがmountedより先に呼ばれている場合はここで描画する
|
||||||
|
if (bitmapTmp) {
|
||||||
|
drawImage(bitmapTmp);
|
||||||
|
}
|
||||||
|
waitForDecode();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
workerPromise.then(worker => {
|
||||||
|
worker?.removeListener(workerOnMessage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.transition_toggle_enterActive,
|
.transition_leaveActive {
|
||||||
.transition_toggle_leaveActive {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition_toggle_enterTo,
|
|
||||||
.transition_toggle_leaveFrom {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@@ -1,6 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="hide" :class="$style.hidden" @click="hide = false">
|
<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
|
||||||
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
|
<a
|
||||||
|
:class="$style.imageContainer"
|
||||||
|
:href="image.url"
|
||||||
|
:title="image.name"
|
||||||
|
>
|
||||||
|
<ImgWithBlurhash
|
||||||
|
:hash="image.blurhash"
|
||||||
|
:src="(defaultStore.state.enableDataSaverMode && hide) ? null : url"
|
||||||
|
:force-blurhash="hide"
|
||||||
|
:cover="hide"
|
||||||
|
:alt="image.comment || image.name"
|
||||||
|
:title="image.comment || image.name"
|
||||||
|
:width="image.properties.width"
|
||||||
|
:height="image.properties.height"
|
||||||
|
:style="hide ? 'filter: brightness(0.5);' : null"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<template v-if="hide">
|
||||||
<div :class="$style.hiddenText">
|
<div :class="$style.hiddenText">
|
||||||
<div :class="$style.hiddenTextWrapper">
|
<div :class="$style.hiddenTextWrapper">
|
||||||
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
|
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
|
||||||
@@ -8,22 +25,16 @@
|
|||||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
|
<template v-else>
|
||||||
<a
|
|
||||||
:class="$style.imageContainer"
|
|
||||||
:href="image.url"
|
|
||||||
:title="image.name"
|
|
||||||
>
|
|
||||||
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/>
|
|
||||||
</a>
|
|
||||||
<div :class="$style.indicators">
|
<div :class="$style.indicators">
|
||||||
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
|
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
|
||||||
<div v-if="image.comment" :class="$style.indicator">ALT</div>
|
<div v-if="image.comment" :class="$style.indicator">ALT</div>
|
||||||
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
|
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click.stop.prevent="hide = true"><i class="ti ti-eye-off"></i></button>
|
||||||
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
|
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -53,6 +64,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
|
|||||||
: props.image.thumbnailUrl,
|
: props.image.thumbnailUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onclick() {
|
||||||
|
if (hide) {
|
||||||
|
hide = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||||
watch(() => props.image, () => {
|
watch(() => props.image, () => {
|
||||||
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||||
|
@@ -7,6 +7,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
$style.medias,
|
$style.medias,
|
||||||
count <= 4 ? $style['n' + count] : $style.nMany,
|
count <= 4 ? $style['n' + count] : $style.nMany,
|
||||||
|
$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref, useCssModule, watch } from 'vue';
|
import { onMounted, ref, useCssModule, watch, shallowRef } from 'vue';
|
||||||
import * as misskey from 'misskey-js';
|
import * as misskey from 'misskey-js';
|
||||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||||
import PhotoSwipe from 'photoswipe';
|
import PhotoSwipe from 'photoswipe';
|
||||||
@@ -38,11 +39,42 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
const gallery = ref<HTMLDivElement>();
|
const gallery = shallowRef<HTMLDivElement>();
|
||||||
const pswpZIndex = os.claimZIndex('middle');
|
const pswpZIndex = os.claimZIndex('middle');
|
||||||
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
||||||
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
|
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||||
|
|
||||||
|
function calcAspectRatio() {
|
||||||
|
if (!gallery.value) return;
|
||||||
|
|
||||||
|
let img = props.mediaList[0];
|
||||||
|
|
||||||
|
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
|
||||||
|
gallery.value.style.aspectRatio = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// アスペクト比上限設定では、横長の場合は高さを縮小させる
|
||||||
|
const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
|
||||||
|
|
||||||
|
switch (defaultStore.state.mediaListWithOneImageAppearance) {
|
||||||
|
case '16_9':
|
||||||
|
gallery.value.style.aspectRatio = ratioMax(16 / 9);
|
||||||
|
break;
|
||||||
|
case '1_1':
|
||||||
|
gallery.value.style.aspectRatio = ratioMax(1);
|
||||||
|
break;
|
||||||
|
case '2_3':
|
||||||
|
gallery.value.style.aspectRatio = ratioMax(2 / 3);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
gallery.value.style.aspectRatio = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio());
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const lightbox = new PhotoSwipeLightbox({
|
const lightbox = new PhotoSwipeLightbox({
|
||||||
dataSource: props.mediaList
|
dataSource: props.mediaList
|
||||||
@@ -162,12 +194,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: 8px;
|
grid-gap: 8px;
|
||||||
|
|
||||||
// for webkit
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&.n1 {
|
&.n1 {
|
||||||
aspect-ratio: 16/9;
|
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
|
|
||||||
|
// default (expand)
|
||||||
|
min-height: 64px;
|
||||||
|
max-height: clamp(
|
||||||
|
64px,
|
||||||
|
50cqh,
|
||||||
|
min(360px, 50vh)
|
||||||
|
);
|
||||||
|
|
||||||
|
&.n116_9 {
|
||||||
|
min-height: none;
|
||||||
|
max-height: none;
|
||||||
|
aspect-ratio: 16 / 9; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
&.n11_1{
|
||||||
|
min-height: none;
|
||||||
|
max-height: none;
|
||||||
|
aspect-ratio: 1 / 1; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
&.n12_3 {
|
||||||
|
min-height: none;
|
||||||
|
max-height: none;
|
||||||
|
aspect-ratio: 2 / 3; // fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.n2 {
|
&.n2 {
|
||||||
|
@@ -31,7 +31,7 @@
|
|||||||
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
|
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
|
||||||
<span v-else><i class="ti ti-rocket-off"></i></span>
|
<span v-else><i class="ti ti-rocket-off"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" :class="['_button', $style.headerRightItem, $style.reactionAcceptance, { [$style.danger]: reactionAcceptance }]" @click="toggleReactionAcceptance">
|
<button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" :class="['_button', $style.headerRightItem, $style.reactionAcceptance, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance">
|
||||||
<span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span>
|
<span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span>
|
||||||
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
|
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
|
||||||
<span v-else><i class="ti ti-icons"></i></span>
|
<span v-else><i class="ti ti-icons"></i></span>
|
||||||
@@ -484,8 +484,10 @@ async function toggleReactionAcceptance() {
|
|||||||
title: i18n.ts.reactionAcceptance,
|
title: i18n.ts.reactionAcceptance,
|
||||||
items: [
|
items: [
|
||||||
{ value: null, text: i18n.ts.all },
|
{ value: null, text: i18n.ts.all },
|
||||||
{ value: 'likeOnly' as const, text: i18n.ts.likeOnly },
|
|
||||||
{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote },
|
{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote },
|
||||||
|
{ value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly },
|
||||||
|
{ value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote },
|
||||||
|
{ value: 'likeOnly' as const, text: i18n.ts.likeOnly },
|
||||||
],
|
],
|
||||||
default: reactionAcceptance,
|
default: reactionAcceptance,
|
||||||
});
|
});
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]"
|
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]"
|
||||||
@click="toggleReaction()"
|
@click="toggleReaction()"
|
||||||
>
|
>
|
||||||
<MkReactionIcon :class="$style.icon" :reaction="reaction" :emoji-url="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/>
|
<MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/>
|
||||||
<span :class="$style.count">{{ count }}</span>
|
<span :class="$style.count">{{ count }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,6 +22,7 @@ import { $i } from '@/account';
|
|||||||
import MkReactionEffect from '@/components/MkReactionEffect.vue';
|
import MkReactionEffect from '@/components/MkReactionEffect.vue';
|
||||||
import { claimAchievement } from '@/scripts/achievements';
|
import { claimAchievement } from '@/scripts/achievements';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reaction: string;
|
reaction: string;
|
||||||
@@ -34,11 +35,19 @@ const buttonEl = shallowRef<HTMLElement>();
|
|||||||
|
|
||||||
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
|
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
|
||||||
|
|
||||||
const toggleReaction = () => {
|
async function toggleReaction() {
|
||||||
if (!canToggle.value) return;
|
if (!canToggle.value) return;
|
||||||
|
|
||||||
|
// TODO: その絵文字を使う権限があるかどうか確認
|
||||||
|
|
||||||
const oldReaction = props.note.myReaction;
|
const oldReaction = props.note.myReaction;
|
||||||
if (oldReaction) {
|
if (oldReaction) {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm,
|
||||||
|
});
|
||||||
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
os.api('notes/reactions/delete', {
|
os.api('notes/reactions/delete', {
|
||||||
noteId: props.note.id,
|
noteId: props.note.id,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -58,9 +67,9 @@ const toggleReaction = () => {
|
|||||||
claimAchievement('reactWithoutRead');
|
claimAchievement('reactWithoutRead');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const anime = () => {
|
function anime() {
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
if (!defaultStore.state.animation) return;
|
if (!defaultStore.state.animation) return;
|
||||||
|
|
||||||
@@ -68,7 +77,7 @@ const anime = () => {
|
|||||||
const x = rect.left + 16;
|
const x = rect.left + 16;
|
||||||
const y = rect.top + (buttonEl.value.offsetHeight / 2);
|
const y = rect.top + (buttonEl.value.offsetHeight / 2);
|
||||||
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
|
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
|
||||||
};
|
}
|
||||||
|
|
||||||
watch(() => props.count, (newCount, oldCount) => {
|
watch(() => props.count, (newCount, oldCount) => {
|
||||||
if (oldCount < newCount) anime();
|
if (oldCount < newCount) anime();
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||||
<img :class="$style.inner" :src="url" decoding="async"/>
|
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true"/>
|
||||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||||
<div v-if="user.isCat" :class="[$style.ears]">
|
<div v-if="user.isCat" :class="[$style.ears]">
|
||||||
<div :class="$style.earLeft">
|
<div :class="$style.earLeft">
|
||||||
@@ -30,6 +30,7 @@ import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-bl
|
|||||||
import { acct, userPage } from '@/filters/user';
|
import { acct, userPage } from '@/filters/user';
|
||||||
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
|
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
|
import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
|
||||||
|
|
||||||
const animation = $ref(defaultStore.state.animation);
|
const animation = $ref(defaultStore.state.animation);
|
||||||
const squareAvatars = $ref(defaultStore.state.squareAvatars);
|
const squareAvatars = $ref(defaultStore.state.squareAvatars);
|
||||||
|
@@ -117,7 +117,7 @@ async function addRole() {
|
|||||||
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id);
|
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id);
|
||||||
|
|
||||||
const { canceled, result: role } = await os.select({
|
const { canceled, result: role } = await os.select({
|
||||||
items: roles.filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
|
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
|
148
packages/frontend/src/pages/list.vue
Normal file
148
packages/frontend/src/pages/list.vue
Normal file
@@ -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>
|
@@ -70,6 +70,7 @@ definePageMetadata({
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: solid 1px var(--divider);
|
border: solid 1px var(--divider);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: solid 1px var(--accent);
|
border: solid 1px var(--accent);
|
||||||
|
@@ -1,10 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer :content-max="700" :class="$style.main">
|
<MkSpacer :contentMax="700" :class="$style.main">
|
||||||
<div v-if="list" class="members _margin">
|
<div v-if="list" class="_gaps">
|
||||||
<div class="">{{ i18n.ts.members }}</div>
|
<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>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder defaultOpen>
|
||||||
|
<template #label>{{ i18n.ts.members }}</template>
|
||||||
|
|
||||||
<div class="_gaps_s">
|
<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">
|
<div v-for="user in users" :key="user.id" :class="$style.userItem">
|
||||||
<MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
|
<MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
|
||||||
<MkUserCardMini :user="user"/>
|
<MkUserCardMini :user="user"/>
|
||||||
@@ -12,24 +30,14 @@
|
|||||||
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
|
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</MkFolder>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</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>
|
|
||||||
</div>
|
|
||||||
</MkSpacer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { mainRouter } from '@/router';
|
import { mainRouter } from '@/router';
|
||||||
@@ -37,6 +45,9 @@ import { definePageMetadata } from '@/scripts/page-metadata';
|
|||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { userPage } from '@/filters/user';
|
import { userPage } from '@/filters/user';
|
||||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
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';
|
import { userListsCache } from '@/cache';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -45,12 +56,17 @@ const props = defineProps<{
|
|||||||
|
|
||||||
let list = $ref(null);
|
let list = $ref(null);
|
||||||
let users = $ref([]);
|
let users = $ref([]);
|
||||||
|
const isPublic = ref(false);
|
||||||
|
const name = ref('');
|
||||||
|
|
||||||
function fetchList() {
|
function fetchList() {
|
||||||
os.api('users/lists/show', {
|
os.api('users/lists/show', {
|
||||||
listId: props.listId,
|
listId: props.listId,
|
||||||
}).then(_list => {
|
}).then(_list => {
|
||||||
list = _list;
|
list = _list;
|
||||||
|
name.value = list.name;
|
||||||
|
isPublic.value = list.isPublic;
|
||||||
|
|
||||||
os.api('users/show', {
|
os.api('users/show', {
|
||||||
userIds: list.userIds,
|
userIds: list.userIds,
|
||||||
}).then(_users => {
|
}).then(_users => {
|
||||||
@@ -86,23 +102,6 @@ async function removeUser(user, ev) {
|
|||||||
}], ev.currentTarget ?? ev.target);
|
}], 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() {
|
async function deleteList() {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@@ -117,6 +116,19 @@ async function deleteList() {
|
|||||||
mainRouter.push('/my/lists');
|
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 });
|
watch(() => props.listId, fetchList, { immediate: true });
|
||||||
|
|
||||||
const headerActions = $computed(() => []);
|
const headerActions = $computed(() => []);
|
||||||
|
@@ -56,7 +56,7 @@
|
|||||||
<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
|
<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
|
||||||
<option value="force">{{ i18n.ts._nsfw.force }}</option>
|
<option value="force">{{ i18n.ts._nsfw.force }}</option>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
<!--
|
|
||||||
<MkRadios v-model="mediaListWithOneImageAppearance">
|
<MkRadios v-model="mediaListWithOneImageAppearance">
|
||||||
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
|
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
|
||||||
<option value="expand">{{ i18n.ts.default }}</option>
|
<option value="expand">{{ i18n.ts.default }}</option>
|
||||||
@@ -64,7 +64,6 @@
|
|||||||
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
|
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
|
||||||
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
|
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
|
||||||
</MkRadios>
|
</MkRadios>
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
@@ -8,21 +8,21 @@
|
|||||||
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
|
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MkInput v-model="profile.name" :max="30" manual-save>
|
<MkInput v-model="profile.name" :max="30" manualSave>
|
||||||
<template #label>{{ i18n.ts._profile.name }}</template>
|
<template #label>{{ i18n.ts._profile.name }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
||||||
<MkTextarea v-model="profile.description" :max="500" tall manual-save>
|
<MkTextarea v-model="profile.description" :max="500" tall manualSave>
|
||||||
<template #label>{{ i18n.ts._profile.description }}</template>
|
<template #label>{{ i18n.ts._profile.description }}</template>
|
||||||
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
|
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
<MkInput v-model="profile.location" manual-save>
|
<MkInput v-model="profile.location" manualSave>
|
||||||
<template #label>{{ i18n.ts.location }}</template>
|
<template #label>{{ i18n.ts.location }}</template>
|
||||||
<template #prefix><i class="ti ti-map-pin"></i></template>
|
<template #prefix><i class="ti ti-map-pin"></i></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
||||||
<MkInput v-model="profile.birthday" type="date" manual-save>
|
<MkInput v-model="profile.birthday" type="date" manualSave>
|
||||||
<template #label>{{ i18n.ts.birthday }}</template>
|
<template #label>{{ i18n.ts.birthday }}</template>
|
||||||
<template #prefix><i class="ti ti-cake"></i></template>
|
<template #prefix><i class="ti ti-cake"></i></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<Sortable
|
<Sortable
|
||||||
v-model="fields"
|
v-model="fields"
|
||||||
class="_gaps_s"
|
class="_gaps_s"
|
||||||
item-key="id"
|
itemKey="id"
|
||||||
:animation="150"
|
:animation="150"
|
||||||
:handle="'.' + $style.dragItemHandle"
|
:handle="'.' + $style.dragItemHandle"
|
||||||
@start="e => e.item.classList.add('active')"
|
@start="e => e.item.classList.add('active')"
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button>
|
<button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button>
|
||||||
<button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button>
|
<button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button>
|
||||||
<div :class="$style.dragItemForm">
|
<div :class="$style.dragItemForm">
|
||||||
<FormSplit :min-width="200">
|
<FormSplit :minWidth="200">
|
||||||
<MkInput v-model="element.name" small>
|
<MkInput v-model="element.name" small>
|
||||||
<template #label>{{ i18n.ts._profile.metadataLabel }}</template>
|
<template #label>{{ i18n.ts._profile.metadataLabel }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
@@ -88,8 +88,10 @@
|
|||||||
<MkSelect v-model="reactionAcceptance">
|
<MkSelect v-model="reactionAcceptance">
|
||||||
<template #label>{{ i18n.ts.reactionAcceptance }}</template>
|
<template #label>{{ i18n.ts.reactionAcceptance }}</template>
|
||||||
<option :value="null">{{ i18n.ts.all }}</option>
|
<option :value="null">{{ i18n.ts.all }}</option>
|
||||||
<option value="likeOnly">{{ i18n.ts.likeOnly }}</option>
|
|
||||||
<option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option>
|
<option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option>
|
||||||
|
<option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option>
|
||||||
|
<option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option>
|
||||||
|
<option value="likeOnly">{{ i18n.ts.likeOnly }}</option>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@@ -10,6 +10,7 @@
|
|||||||
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
|
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
|
||||||
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
||||||
<XClips v-else-if="tab === 'clips'" :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"/>
|
<XPages v-else-if="tab === 'pages'" :user="user"/>
|
||||||
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
|
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,6 +37,7 @@ const XActivity = defineAsyncComponent(() => import('./activity.vue'));
|
|||||||
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
|
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
|
||||||
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
|
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
|
||||||
const XClips = defineAsyncComponent(() => import('./clips.vue'));
|
const XClips = defineAsyncComponent(() => import('./clips.vue'));
|
||||||
|
const XLists = defineAsyncComponent(() => import('./lists.vue'));
|
||||||
const XPages = defineAsyncComponent(() => import('./pages.vue'));
|
const XPages = defineAsyncComponent(() => import('./pages.vue'));
|
||||||
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
|
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
|
||||||
|
|
||||||
@@ -90,6 +92,10 @@ const headerTabs = $computed(() => user ? [{
|
|||||||
key: 'clips',
|
key: 'clips',
|
||||||
title: i18n.ts.clips,
|
title: i18n.ts.clips,
|
||||||
icon: 'ti ti-paperclip',
|
icon: 'ti ti-paperclip',
|
||||||
|
}, {
|
||||||
|
key: 'lists',
|
||||||
|
title: i18n.ts.lists,
|
||||||
|
icon: 'ti ti-list',
|
||||||
}, {
|
}, {
|
||||||
key: 'pages',
|
key: 'pages',
|
||||||
title: i18n.ts.pages,
|
title: i18n.ts.pages,
|
||||||
|
51
packages/frontend/src/pages/user/lists.vue
Normal file
51
packages/frontend/src/pages/user/lists.vue
Normal file
@@ -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>
|
@@ -30,6 +30,10 @@ export const routes = [{
|
|||||||
name: 'note',
|
name: 'note',
|
||||||
path: '/notes/:noteId',
|
path: '/notes/:noteId',
|
||||||
component: page(() => import('./pages/note.vue')),
|
component: page(() => import('./pages/note.vue')),
|
||||||
|
}, {
|
||||||
|
name: 'list',
|
||||||
|
path: '/list/:listId',
|
||||||
|
component: page(() => import('./pages/list.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/clips/:clipId',
|
path: '/clips/:clipId',
|
||||||
component: page(() => import('./pages/clip.vue')),
|
component: page(() => import('./pages/clip.vue')),
|
||||||
|
75
packages/frontend/src/scripts/worker-multi-dispatch.ts
Normal file
75
packages/frontend/src/scripts/worker-multi-dispatch.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
function defaultUseWorkerNumber(prev: number, totalWorkers: number) {
|
||||||
|
return prev + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkerMultiDispatch<POST = any, RETURN = any> {
|
||||||
|
private symbol = Symbol('WorkerMultiDispatch');
|
||||||
|
private workers: Worker[] = [];
|
||||||
|
private terminated = false;
|
||||||
|
private prevWorkerNumber = 0;
|
||||||
|
private getUseWorkerNumber = defaultUseWorkerNumber;
|
||||||
|
private finalizationRegistry: FinalizationRegistry<symbol>;
|
||||||
|
|
||||||
|
constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
|
||||||
|
this.getUseWorkerNumber = getUseWorkerNumber;
|
||||||
|
for (let i = 0; i < concurrency; i++) {
|
||||||
|
this.workers.push(workerConstructor());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finalizationRegistry = new FinalizationRegistry(() => {
|
||||||
|
this.terminate();
|
||||||
|
});
|
||||||
|
this.finalizationRegistry.register(this, this.symbol);
|
||||||
|
|
||||||
|
if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) {
|
||||||
|
let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
|
||||||
|
workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
|
||||||
|
if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
|
||||||
|
this.prevWorkerNumber = workerNumber;
|
||||||
|
|
||||||
|
// 不毛だがunionをoverloadに突っ込めない
|
||||||
|
// https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/14107
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
this.workers[workerNumber].postMessage(message, options);
|
||||||
|
} else {
|
||||||
|
this.workers[workerNumber].postMessage(message, options);
|
||||||
|
}
|
||||||
|
return workerNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
|
||||||
|
this.workers.forEach(worker => {
|
||||||
|
worker.addEventListener('message', callback, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
|
||||||
|
this.workers.forEach(worker => {
|
||||||
|
worker.removeEventListener('message', callback, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
|
||||||
|
this.workers.forEach(worker => {
|
||||||
|
worker.terminate();
|
||||||
|
});
|
||||||
|
this.workers = [];
|
||||||
|
this.finalizationRegistry.unregister(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isTerminated() {
|
||||||
|
return this.terminated;
|
||||||
|
}
|
||||||
|
public getWorkers() {
|
||||||
|
return this.workers;
|
||||||
|
}
|
||||||
|
public getSymbol() {
|
||||||
|
return this.symbol;
|
||||||
|
}
|
||||||
|
}
|
@@ -92,7 +92,7 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||||||
},
|
},
|
||||||
reactionAcceptance: {
|
reactionAcceptance: {
|
||||||
where: 'account',
|
where: 'account',
|
||||||
default: null as 'likeOnly' | 'likeOnlyForRemote' | null,
|
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
|
||||||
},
|
},
|
||||||
mutedWords: {
|
mutedWords: {
|
||||||
where: 'account',
|
where: 'account',
|
||||||
|
15
packages/frontend/src/workers/draw-blurhash.ts
Normal file
15
packages/frontend/src/workers/draw-blurhash.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { render } from 'buraha';
|
||||||
|
|
||||||
|
onmessage = (event) => {
|
||||||
|
// console.log(event.data);
|
||||||
|
if (!('id' in event.data && typeof event.data.id === 'string')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!('hash' in event.data && typeof event.data.hash === 'string')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const work = new OffscreenCanvas(event.data.width ?? 64, event.data.height ?? 64);
|
||||||
|
render(event.data.hash, work);
|
||||||
|
const bitmap = work.transferToImageBitmap();
|
||||||
|
postMessage({ id: event.data.id, bitmap });
|
||||||
|
};
|
7
packages/frontend/src/workers/test-webgl2.ts
Normal file
7
packages/frontend/src/workers/test-webgl2.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const canvas = new OffscreenCanvas(1, 1);
|
||||||
|
const gl = canvas.getContext('webgl2');
|
||||||
|
if (gl) {
|
||||||
|
postMessage({ result: true });
|
||||||
|
} else {
|
||||||
|
postMessage({ result: false });
|
||||||
|
}
|
5
packages/frontend/src/workers/tsconfig.json
Normal file
5
packages/frontend/src/workers/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["esnext", "webworker"],
|
||||||
|
}
|
||||||
|
}
|
@@ -139,6 +139,10 @@ export function getConfig(): UserConfig {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
|
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
deps: {
|
deps: {
|
||||||
|
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -657,15 +657,15 @@ importers:
|
|||||||
autosize:
|
autosize:
|
||||||
specifier: 6.0.1
|
specifier: 6.0.1
|
||||||
version: 6.0.1
|
version: 6.0.1
|
||||||
blurhash:
|
|
||||||
specifier: 2.0.5
|
|
||||||
version: 2.0.5
|
|
||||||
broadcast-channel:
|
broadcast-channel:
|
||||||
specifier: 4.20.2
|
specifier: 4.20.2
|
||||||
version: 4.20.2
|
version: 4.20.2
|
||||||
browser-image-resizer:
|
browser-image-resizer:
|
||||||
specifier: github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3
|
specifier: github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3
|
||||||
version: github.com/misskey-dev/browser-image-resizer/0227e860621e55cbed0aabe6dc601096a7748c4a
|
version: github.com/misskey-dev/browser-image-resizer/0227e860621e55cbed0aabe6dc601096a7748c4a
|
||||||
|
buraha:
|
||||||
|
specifier: github:misskey-dev/buraha
|
||||||
|
version: github.com/misskey-dev/buraha/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c
|
||||||
canvas-confetti:
|
canvas-confetti:
|
||||||
specifier: 1.6.0
|
specifier: 1.6.0
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
@@ -20410,6 +20410,12 @@ packages:
|
|||||||
version: 2.2.1-misskey.3
|
version: 2.2.1-misskey.3
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
github.com/misskey-dev/buraha/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c:
|
||||||
|
resolution: {tarball: https://codeload.github.com/misskey-dev/buraha/tar.gz/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c}
|
||||||
|
name: buraha
|
||||||
|
version: 0.0.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
github.com/misskey-dev/sharp-read-bmp/02d9dc189fa7df0c4bea09330be26741772dac01:
|
github.com/misskey-dev/sharp-read-bmp/02d9dc189fa7df0c4bea09330be26741772dac01:
|
||||||
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/02d9dc189fa7df0c4bea09330be26741772dac01}
|
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/02d9dc189fa7df0c4bea09330be26741772dac01}
|
||||||
name: sharp-read-bmp
|
name: sharp-read-bmp
|
||||||
|
Reference in New Issue
Block a user