Compare commits

..

8 Commits

Author SHA1 Message Date
syuilo
0c0ae6ff90 13.13.0-beta.2 2023-05-19 10:07:29 +09:00
syuilo
d63b943116 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-05-19 10:06:33 +09:00
Chocolate Pie
dddbc1c894 feat: 公開リスト (#10842)
* feat: まず公開できるように (misskey-dev/misskey#10447)

* feat: 公開したリストのページを作成 (misskey-dev/misskey#10447)

* feat: いいねできるように

* feat: インポートに対応

* wip

* wip

* CHANGELOGを編集

* add note

* refactor

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-05-19 10:06:12 +09:00
syuilo
f68c743f39 add note 2023-05-19 09:48:48 +09:00
tamaina
59255e11b8 perf: MkImgWithBlurhashとMkMediaImageを最適化 (#10782)
* #10781

* fix tsconfig

* fetch image??

* Revert "fetch image??"

This reverts commit 0925c28d5a.

* wip

* Revert "wip"

This reverts commit be97c6cb88.

* loading="eager"

* loading="eager" 2

* error

* wip

* wip

* wip

* wip

* clean up

* fix

* 生成するworkerを1つにする?

* clean up

* use buraha

* wip

* smaller width, height

* update buraha

* clean up

* fix

* Update MkMediaImage.vue

* Update MkImgWithBlurhash.vue

* Revert "fix(frontend): センシティブ設定された画像を開くとき一瞬レイアウトが崩れる問題を修正"

This reverts commit 41e9aa6f9b.

* Update MkMediaList.vue

* Update MkMediaList.vue

* Update MkMediaList.vue

* Update CHANGELOG.md

* wait for decode

* fix

* ?

* (test) remove container-type: inline-size;

* Revert "(test) remove container-type: inline-size;"

This reverts commit 9448e64228.

* container-name

* Revert "container-name"

This reverts commit 94385d3221.

* width: 100%;

* improve performance

* refactor

* wip

* WIP

* wip

* Revert "wip"

This reverts commit 36e3b75cab.

* Revert "WIP"

This reverts commit 05b729ef91.

* Revert "wip"

This reverts commit 0801e79361.

* #10860

* wip

* no worker

* Revert "no worker"

This reverts commit a9c49e4fb4.

* ✌️

* workerNumber固定は不要

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-05-19 09:44:06 +09:00
syuilo
3804c6e7ad feat: センシティブなカスタム絵文字のリアクションを受け入れない設定を追加 2023-05-19 09:43:38 +09:00
syuilo
527a13b77d enhance(frontend): リアクションの取り消し/変更時に確認ダイアログを出すように 2023-05-19 09:15:24 +09:00
syuilo
a3423bad60 tweak 2023-05-19 09:14:54 +09:00
48 changed files with 1137 additions and 166 deletions

View File

@@ -17,10 +17,14 @@
### General
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
- カスタム絵文字ごとに連合するかどうか設定できるように
- カスタム絵文字ごとにセンシティブフラグを設定できるように
- センシティブなカスタム絵文字のリアクションを受け入れない設定が可能に
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります
- リストを公開できるようになりました
### Client
- リアクションの取り消し/変更時に確認ダイアログを出すように
- 開発者モードを追加
- AiScriptを0.13.3に更新
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
@@ -100,6 +104,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
* 画像が全て隠れた状態で表示されるようになります
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
- モデレーターはートに添付された画像上から直接NSFW設定できるように
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
- 新しい実績を追加
- AiScriptを0.13.2に更新

View File

@@ -990,7 +990,9 @@ postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後から変更できません。"
reactionAcceptance: "リアクションの受け入れ"
likeOnly: "いいねのみ"
likeOnlyForRemote: "リモートからはいいねのみ"
likeOnlyForRemote: "全て (リモートはいいねのみ)"
nonSensitiveOnly: "非センシティブのみ"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)"
rolesAssignedToMe: "自分に割り当てられたロール"
resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード"
@@ -1053,6 +1055,8 @@ update: "更新"
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールである必要があります。"
cancelReactionConfirm: "リアクションを取り消しますか?"
changeReactionConfirm: "リアクションを変更しますか?"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "13.13.0-beta.1",
"version": "13.13.0-beta.2",
"codename": "nasubi",
"repository": {
"type": "git",

View 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"`);
}
}

View File

@@ -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"`);
}
}

View File

@@ -106,7 +106,7 @@ export class ReactionService {
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 = '❤️';
} else if (_reaction) {
const custom = reaction.match(isCustomEmojiRegexp);
@@ -124,6 +124,11 @@ export class ReactionService {
if (emoji) {
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
// センシティブ
if ((note.reactionAcceptance === 'nonSensitiveOnly') && emoji.isSensitive) {
reaction = FALLBACK;
}
} else {
// リアクションとして使う権限がない
reaction = FALLBACK;

View File

@@ -35,6 +35,7 @@ export class UserListEntityService {
createdAt: userList.createdAt.toISOString(),
name: userList.name,
userIds: users.map(x => x.userId),
isPublic: userList.isPublic,
};
}
}

View File

@@ -25,6 +25,7 @@ export const DI = {
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),
userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListJoiningsRepository: Symbol('userListJoiningsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'),

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -112,6 +112,12 @@ const $userListsRepository: Provider = {
inject: [DI.db],
};
const $userListFavoritesRepository: Provider = {
provide: DI.userListFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(UserListFavorite),
inject: [DI.db],
};
const $userListJoiningsRepository: Provider = {
provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
@@ -416,6 +422,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
$userListFavoritesRepository,
$userListJoiningsRepository,
$userNotePiningsRepository,
$userIpsRepository,
@@ -483,6 +490,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
$userListFavoritesRepository,
$userListJoiningsRepository,
$userNotePiningsRepository,
$userIpsRepository,

View File

@@ -90,7 +90,7 @@ export class Note {
@Column('varchar', {
length: 64, nullable: true,
})
public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null;
public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
@Column('smallint', {
default: 0,

View File

@@ -19,6 +19,12 @@ export class UserList {
})
public userId: User['id'];
@Index()
@Column('boolean', {
default: false,
})
public isPublic: boolean;
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})

View 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;
}

View File

@@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js';
import { UserListFavorite } from './entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js';
@@ -117,6 +118,7 @@ export {
UserIp,
UserKeypair,
UserList,
UserListFavorite,
UserListJoining,
UserNotePining,
UserPending,
@@ -184,6 +186,7 @@ export type UsersRepository = Repository<User>;
export type UserIpsRepository = Repository<UserIp>;
export type UserKeypairsRepository = Repository<UserKeypair>;
export type UserListsRepository = Repository<UserList>;
export type UserListFavoritesRepository = Repository<UserListFavorite>;
export type UserListJoiningsRepository = Repository<UserListJoining>;
export type UserNotePiningsRepository = Repository<UserNotePining>;
export type UserPendingsRepository = Repository<UserPending>;

View File

@@ -25,5 +25,10 @@ export const packedUserListSchema = {
format: 'id',
},
},
isPublic: {
type: 'boolean',
nullable: false,
optional: false,
},
},
} as const;

View File

@@ -57,6 +57,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js';
import { UserListFavorite } from '@/models/entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js';
@@ -132,6 +133,7 @@ export const entities = [
UserKeypair,
UserPublickey,
UserList,
UserListFavorite,
UserListJoining,
UserNotePining,
UserSecurityKey,

View File

@@ -321,6 +321,9 @@ import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_reactions from './endpoints/users/reactions.js';
@@ -659,6 +662,9 @@ const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass:
const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default };
const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default };
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default };
@@ -1001,6 +1007,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push,
$users_lists_show,
$users_lists_update,
$users_lists_favorite,
$users_lists_unfavorite,
$users_lists_create_from_public,
$users_notes,
$users_pages,
$users_reactions,
@@ -1335,6 +1344,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push,
$users_lists_show,
$users_lists_update,
$users_lists_favorite,
$users_lists_unfavorite,
$users_lists_create_from_public,
$users_notes,
$users_pages,
$users_reactions,

View File

@@ -320,6 +320,9 @@ import * as ep___users_lists_list from './endpoints/users/lists/list.js';
import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
@@ -656,7 +659,10 @@ const eps = [
['users/lists/pull', ep___users_lists_pull],
['users/lists/push', ep___users_lists_push],
['users/lists/show', ep___users_lists_show],
['users/lists/favorite', ep___users_lists_favorite],
['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update],
['users/lists/create-from-public', ep___users_lists_create_from_public],
['users/notes', ep___users_notes],
['users/pages', ep___users_pages],
['users/reactions', ep___users_reactions],

View File

@@ -99,7 +99,7 @@ export const paramDef = {
} },
cw: { type: 'string', nullable: true, maxLength: 100 },
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 },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },

View File

@@ -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);
});
}
}

View File

@@ -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,
});
});
}
}

View File

@@ -1,13 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/index.js';
import type { UserListsRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['lists', 'account'],
requireCredential: true,
requireCredential: false,
kind: 'read:account',
@@ -22,26 +23,58 @@ export const meta = {
ref: 'UserList',
},
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e',
},
remoteUser: {
message: 'Not allowed to load the remote user\'s list',
code: 'REMOTE_USER_NOT_ALLOWED',
id: '53858f1b-3315-4a01-81b7-db9b48d4b79a',
},
invalidParam: {
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: 'ab36de0e-29e9-48cb-9732-d82f1281620d',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const userLists = await this.userListsRepository.findBy({
if (typeof ps.userId !== 'undefined') {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user === null) throw new ApiError(meta.errors.noSuchUser);
if (user.host !== null) throw new ApiError(meta.errors.remoteUser);
} else if (me === null) {
throw new ApiError(meta.errors.invalidParam);
}
const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? {
userId: me.id,
} : {
userId: ps.userId,
isPublic: true,
});
return await Promise.all(userLists.map(x => this.userListEntityService.pack(x)));

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository } from '@/models/index.js';
import type { UserListsRepository, UserListFavoritesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
@@ -8,7 +8,7 @@ import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'account'],
requireCredential: true,
requireCredential: false,
kind: 'read:account',
@@ -33,31 +33,54 @@ export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
forPublic: { type: 'boolean', default: false },
},
required: ['listId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListFavoritesRepository)
private userListFavoritesRepository: UserListFavoritesRepository,
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {};
// Fetch the list
const userList = await this.userListsRepository.findOneBy({
const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId,
userId: me.id,
} : {
id: ps.listId,
isPublic: true,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
return await this.userListEntityService.pack(userList);
if (ps.forPublic && userList.isPublic) {
additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({
userListId: ps.listId,
});
if (me !== null) {
additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({
userId: me.id,
userListId: ps.listId,
}) !== null);
} else {
additionalProperties.isLiked = false;
}
}
return {
...await this.userListEntityService.pack(userList),
...additionalProperties,
};
});
}
}

View File

@@ -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 });
});
}
}

View File

@@ -34,8 +34,9 @@ export const paramDef = {
properties: {
listId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean' },
},
required: ['listId', 'name'],
required: ['listId'],
} as const;
// eslint-disable-next-line import/no-default-export
@@ -48,7 +49,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch the list
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
@@ -60,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userListsRepository.update(userList.id, {
name: ps.name,
isPublic: ps.isPublic,
});
return await this.userListEntityService.pack(userList.id);

View File

@@ -25,9 +25,9 @@
"@vue-macros/reactivity-transform": "0.3.7",
"@vue/compiler-sfc": "3.3.2",
"autosize": "6.0.1",
"blurhash": "2.0.5",
"broadcast-channel": "4.20.2",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "github:misskey-dev/buraha",
"canvas-confetti": "1.6.0",
"chart.js": "4.3.0",
"chartjs-adapter-date-fns": "3.0.0",

View File

@@ -5,12 +5,9 @@
<ImgWithBlurhash
class="img layered"
:transition="safe ? null : {
enterActiveClass: $style.transition_toggle_enterActive,
duration: 500,
leaveActiveClass: $style.transition_toggle_leaveActive,
enterFromClass: $style.transition_toggle_enterFrom,
leaveToClass: $style.transition_toggle_leaveTo,
enterToClass: $style.transition_toggle_enterTo,
leaveFromClass: $style.transition_toggle_leaveFrom,
}"
:src="post.files[0].thumbnailUrl"
:hash="post.files[0].blurhash"
@@ -53,24 +50,16 @@ function leaveHover(): void {
</script>
<style lang="scss" module>
.transition_toggle_enterActive,
.transition_toggle_leaveActive {
transition: opacity 0.5s;
transition: opacity .5s;
position: absolute;
top: 0;
left: 0;
}
.transition_toggle_enterFrom,
.transition_toggle_leaveTo {
opacity: 0;
}
.transition_toggle_enterTo,
.transition_toggle_leaveFrom {
transition: none;
opacity: 1;
}
</style>
<style lang="scss" scoped>

View File

@@ -1,30 +1,56 @@
<template>
<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
<img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/>
<Transition
mode="in-out"
: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_toggle_leaveActive']) || undefined"
<div ref="root" :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
<TransitionGroup
:duration="defaultStore.state.animation && props.transition?.duration || undefined"
:enter-active-class="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
:leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_leaveActive']) || undefined"
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || 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"
:leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined"
:enter-to-class="defaultStore.state.animation && props.transition?.enterToClass || 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"/>
<img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/>
</Transition>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? 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"/>
</TransitionGroup>
</div>
</template>
<script lang="ts" setup>
import { onMounted, shallowRef, useCssModule, watch } from 'vue';
import { decode } from 'blurhash';
import { defaultStore } from '@/store';
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
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 props = withDefaults(defineProps<{
transition?: {
duration?: number | { enter: number; leave: number; };
enterActiveClass?: string;
leaveActiveClass?: string;
enterFromClass?: string;
@@ -51,67 +77,141 @@ const props = withDefaults(defineProps<{
forceBlurhash: false,
});
const viewId = uuid();
const canvas = shallowRef<HTMLCanvasElement>();
const root = shallowRef<HTMLDivElement>();
const img = shallowRef<HTMLImageElement>();
let loaded = $ref(false);
let width = $ref(props.width);
let height = $ref(props.height);
let canvasWidth = $ref(64);
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() {
loaded = true;
function waitForDecode() {
if (props.src != null && props.src !== '') {
nextTick()
.then(() => img.value?.decode())
.then(() => {
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;
if (ratio > 1) {
width = Math.round(64 * ratio);
height = 64;
canvasWidth = Math.round(64 * ratio);
canvasHeight = 64;
} else {
width = 64;
height = Math.round(64 / ratio);
canvasWidth = 64;
canvasHeight = Math.round(64 / ratio);
}
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth = clientWidth;
imgHeight = Math.round(clientWidth / ratio);
}, {
immediate: true,
});
function draw() {
if (props.hash == null || !canvas.value) return;
const pixels = decode(props.hash, width, height);
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp = undefined;
const ctx = canvas.value.getContext('2d');
const imageData = ctx!.createImageData(width, height);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
}
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();
});
onMounted(() => {
draw();
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp) {
drawImage(bitmapTmp);
}
waitForDecode();
});
onUnmounted(() => {
workerPromise.then(worker => {
worker?.removeListener(workerOnMessage);
});
});
</script>
<style lang="scss" module>
.transition_toggle_enterActive,
.transition_toggle_leaveActive {
.transition_leaveActive {
position: absolute;
top: 0;
left: 0;
}
.transition_toggle_enterTo,
.transition_toggle_leaveFrom {
opacity: 0;
}
.loader {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
}
.root {
position: relative;
width: 100%;

View File

@@ -1,29 +1,40 @@
<template>
<div v-if="hide" :class="$style.hidden" @click="hide = false">
<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"/>
<div :class="$style.hiddenText">
<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-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</div>
<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
<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"/>
<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>
<div :class="$style.indicators">
<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.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</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 :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
<template v-if="hide">
<div :class="$style.hiddenText">
<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-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</template>
<template v-else>
<div :class="$style.indicators">
<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.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
</div>
<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>
</template>
</div>
</template>
@@ -53,6 +64,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
: props.image.thumbnailUrl,
);
function onclick() {
if (hide) {
hide = false;
}
}
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
watch(() => props.image, () => {
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');

View File

@@ -7,6 +7,7 @@
:class="[
$style.medias,
count <= 4 ? $style['n' + count] : $style.nMany,
$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
@@ -19,7 +20,7 @@
</template>
<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 PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
@@ -38,11 +39,42 @@ const props = defineProps<{
const $style = useCssModule();
const gallery = ref<HTMLDivElement>();
const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
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(() => {
const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
@@ -162,12 +194,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
display: grid;
grid-gap: 8px;
// for webkit
height: 100%;
width: 100%;
&.n1 {
aspect-ratio: 16/9;
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 {

View File

@@ -31,7 +31,7 @@
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</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-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
<span v-else><i class="ti ti-icons"></i></span>
@@ -484,8 +484,10 @@ async function toggleReactionAcceptance() {
title: i18n.ts.reactionAcceptance,
items: [
{ value: null, text: i18n.ts.all },
{ value: 'likeOnly' as const, text: i18n.ts.likeOnly },
{ 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,
});

View File

@@ -6,7 +6,7 @@
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]"
@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>
</button>
</template>
@@ -22,6 +22,7 @@ import { $i } from '@/account';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/scripts/achievements';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
const props = defineProps<{
reaction: string;
@@ -34,11 +35,19 @@ const buttonEl = shallowRef<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
const toggleReaction = () => {
async function toggleReaction() {
if (!canToggle.value) return;
// TODO: その絵文字を使う権限があるかどうか確認
const oldReaction = props.note.myReaction;
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', {
noteId: props.note.id,
}).then(() => {
@@ -58,9 +67,9 @@ const toggleReaction = () => {
claimAchievement('reactWithoutRead');
}
}
};
}
const anime = () => {
function anime() {
if (document.hidden) return;
if (!defaultStore.state.animation) return;
@@ -68,7 +77,7 @@ const anime = () => {
const x = rect.left + 16;
const y = rect.top + (buttonEl.value.offsetHeight / 2);
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
};
}
watch(() => props.count, (newCount, oldCount) => {
if (oldCount < newCount) anime();

View File

@@ -1,6 +1,6 @@
<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">
<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"/>
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
@@ -30,6 +30,7 @@ import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-bl
import { acct, userPage } from '@/filters/user';
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
import { defaultStore } from '@/store';
import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
const animation = $ref(defaultStore.state.animation);
const squareAvatars = $ref(defaultStore.state.squareAvatars);

View File

@@ -117,7 +117,7 @@ async function addRole() {
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id);
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;

View 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>

View File

@@ -70,6 +70,7 @@ definePageMetadata({
padding: 16px;
border: solid 1px var(--divider);
border-radius: 6px;
margin-bottom: 8px;
&:hover {
border: solid 1px var(--accent);

View File

@@ -1,35 +1,43 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :class="$style.main">
<div v-if="list" class="members _margin">
<div class="">{{ i18n.ts.members }}</div>
<div class="_gaps_s">
<div v-for="user in users" :key="user.id" :class="$style.userItem">
<MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
<MkUserCardMini :user="user"/>
</MkA>
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
<MkSpacer :contentMax="700" :class="$style.main">
<div v-if="list" class="_gaps">
<MkFolder>
<template #label>{{ i18n.ts.settings }}</template>
<div class="_gaps">
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkSwitch v-model="isPublic">{{ i18n.ts.public }}</MkSwitch>
<div class="_buttons">
<MkButton rounded primary @click="updateSettings">{{ i18n.ts.save }}</MkButton>
<MkButton rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
</div>
</div>
</div>
</MkFolder>
<MkFolder defaultOpen>
<template #label>{{ i18n.ts.members }}</template>
<div class="_gaps_s">
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
<div v-for="user in users" :key="user.id" :class="$style.userItem">
<MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
<MkUserCardMini :user="user"/>
</MkA>
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
</div>
</div>
</MkFolder>
</div>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
<div class="_buttons">
<MkButton inline rounded primary @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
<MkButton inline rounded @click="renameList()">{{ i18n.ts.rename }}</MkButton>
<MkButton inline rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { mainRouter } from '@/router';
@@ -37,6 +45,9 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import { userPage } from '@/filters/user';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue';
import { userListsCache } from '@/cache';
const props = defineProps<{
@@ -45,12 +56,17 @@ const props = defineProps<{
let list = $ref(null);
let users = $ref([]);
const isPublic = ref(false);
const name = ref('');
function fetchList() {
os.api('users/lists/show', {
listId: props.listId,
}).then(_list => {
list = _list;
name.value = list.name;
isPublic.value = list.isPublic;
os.api('users/show', {
userIds: list.userIds,
}).then(_users => {
@@ -86,23 +102,6 @@ async function removeUser(user, ev) {
}], ev.currentTarget ?? ev.target);
}
async function renameList() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.enterListName,
default: list.name,
});
if (canceled) return;
await os.api('users/lists/update', {
listId: list.id,
name: name,
});
userListsCache.delete();
list.name = name;
}
async function deleteList() {
const { canceled } = await os.confirm({
type: 'warning',
@@ -117,6 +116,19 @@ async function deleteList() {
mainRouter.push('/my/lists');
}
async function updateSettings() {
await os.apiWithDialog('users/lists/update', {
listId: list.id,
name: name.value,
isPublic: isPublic.value,
});
userListsCache.delete();
list.name = name.value;
list.isPublic = isPublic.value;
}
watch(() => props.listId, fetchList, { immediate: true });
const headerActions = $computed(() => []);

View File

@@ -56,7 +56,7 @@
<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
<option value="force">{{ i18n.ts._nsfw.force }}</option>
</MkSelect>
<!--
<MkRadios v-model="mediaListWithOneImageAppearance">
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
<option value="expand">{{ i18n.ts.default }}</option>
@@ -64,7 +64,6 @@
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
</MkRadios>
-->
</div>
</FormSection>

View File

@@ -8,21 +8,21 @@
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
</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>
</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 #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
<MkInput v-model="profile.location" manual-save>
<MkInput v-model="profile.location" manualSave>
<template #label>{{ i18n.ts.location }}</template>
<template #prefix><i class="ti ti-map-pin"></i></template>
</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 #prefix><i class="ti ti-cake"></i></template>
</MkInput>
@@ -48,7 +48,7 @@
<Sortable
v-model="fields"
class="_gaps_s"
item-key="id"
itemKey="id"
:animation="150"
:handle="'.' + $style.dragItemHandle"
@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" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button>
<div :class="$style.dragItemForm">
<FormSplit :min-width="200">
<FormSplit :minWidth="200">
<MkInput v-model="element.name" small>
<template #label>{{ i18n.ts._profile.metadataLabel }}</template>
</MkInput>
@@ -88,8 +88,10 @@
<MkSelect v-model="reactionAcceptance">
<template #label>{{ i18n.ts.reactionAcceptance }}</template>
<option :value="null">{{ i18n.ts.all }}</option>
<option value="likeOnly">{{ i18n.ts.likeOnly }}</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>
</div>
</template>

View File

@@ -10,6 +10,7 @@
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/>
<XLists v-else-if="tab === 'lists'" :user="user"/>
<XPages v-else-if="tab === 'pages'" :user="user"/>
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
</div>
@@ -36,6 +37,7 @@ const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
const XClips = defineAsyncComponent(() => import('./clips.vue'));
const XLists = defineAsyncComponent(() => import('./lists.vue'));
const XPages = defineAsyncComponent(() => import('./pages.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
@@ -90,6 +92,10 @@ const headerTabs = $computed(() => user ? [{
key: 'clips',
title: i18n.ts.clips,
icon: 'ti ti-paperclip',
}, {
key: 'lists',
title: i18n.ts.lists,
icon: 'ti ti-list',
}, {
key: 'pages',
title: i18n.ts.pages,

View 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>

View File

@@ -30,6 +30,10 @@ export const routes = [{
name: 'note',
path: '/notes/:noteId',
component: page(() => import('./pages/note.vue')),
}, {
name: 'list',
path: '/list/:listId',
component: page(() => import('./pages/list.vue')),
}, {
path: '/clips/:clipId',
component: page(() => import('./pages/clip.vue')),

View 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;
}
}

View File

@@ -92,7 +92,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
reactionAcceptance: {
where: 'account',
default: null as 'likeOnly' | 'likeOnlyForRemote' | null,
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
},
mutedWords: {
where: 'account',

View 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 });
};

View 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 });
}

View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"lib": ["esnext", "webworker"],
}
}

View File

@@ -139,6 +139,10 @@ export function getConfig(): UserConfig {
},
},
worker: {
format: 'es',
},
test: {
environment: 'happy-dom',
deps: {

12
pnpm-lock.yaml generated
View File

@@ -657,15 +657,15 @@ importers:
autosize:
specifier: 6.0.1
version: 6.0.1
blurhash:
specifier: 2.0.5
version: 2.0.5
broadcast-channel:
specifier: 4.20.2
version: 4.20.2
browser-image-resizer:
specifier: github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3
version: github.com/misskey-dev/browser-image-resizer/0227e860621e55cbed0aabe6dc601096a7748c4a
buraha:
specifier: github:misskey-dev/buraha
version: github.com/misskey-dev/buraha/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c
canvas-confetti:
specifier: 1.6.0
version: 1.6.0
@@ -20410,6 +20410,12 @@ packages:
version: 2.2.1-misskey.3
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:
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/02d9dc189fa7df0c4bea09330be26741772dac01}
name: sharp-read-bmp