Compare commits

..

14 Commits

Author SHA1 Message Date
かっこかり e216748a21 enhance(frontend): インライン表示上で既にぼかしが解除されている場合はlightbox上でもぼかさないように (#17866)
* enhance(frontend): インライン表示上で既にぼかしが解除されている場合はlightbox上でもぼかさないように

* Update Changelog
2026-08-19 20:58:39 +09:00
syuilo ff7f2aeb7a Update CHANGELOG.md 2026-08-19 20:56:47 +09:00
anatawa12 0c0733d11e Migrate pkcs#1 keys to pkcs#8 key on loading keys (#17872)
* chore; migrate pkcs#1 key to pkcs#8 key

* fix typo

* fix: missing semi and correctly reference nodeCrypto

* fix: js uses includes instead of contains

* docs: fix comment

* chore: change cache key for UserKeypair to flush cache
2026-08-19 20:55:29 +09:00
かっこかり 27b0372783 fix(gh): リリースPRがあるとpush/PRで2回ワークフローが実行される問題を修正 (#17878) 2026-08-19 19:52:40 +09:00
かっこかり 8da1b52c9b fix(frontend): follow-up of 638307f 2026-08-19 14:19:11 +09:00
かっこかり 638307ff3e fix(frontend): Follow-up of #17845 2026-08-19 14:17:04 +09:00
syuilo 7aa0089a5a Update CHANGELOG.md 2026-08-19 12:50:38 +09:00
かっこかり 3129d1ac4f perf(frontend): ワードミュートの正規表現・前処理結果をキャッシュし判定を高速化 (#17875) 2026-08-19 12:50:11 +09:00
かっこかり 819a66d610 perf(frontend): ノートがプラグインによって非表示になった場合は以降のプラグイン処理をスキップするように (#17873) 2026-08-19 12:48:18 +09:00
かっこかり dacda056fb enhance(frontend): 音声の再生をMkLightboxに統合 (#16314)
* enhance(frontend): 音声ビジュアライザー

* fix

* fix: avatarの縦横比が1:1ではない場合に画像がゆがむ問題を修正

* fix

* remove unnecessary code

* fix lint

* fix

* move media controls component

* refactor media control

* wip

* wip

* wip

* fix

* improve audio visualizer

* tweak/refactor

* fix: 音声のプレビューができなくても再生はできるように

* fix lint

* 🎨

* fix

* Update Changelog

* fix

* fix review

* fix

* fix

* fix

* update

* clean up

* fix: drivefileのuserがnullの場合にavatarが表示されないのを修正

* fix
2026-08-19 12:45:07 +09:00
syuilo ab26d2b7b2 enhance: ノート翻訳時、CWも翻訳対象に含めるように 2026-08-18 20:10:13 +09:00
syuilo 12f0fb7738 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-08-18 20:04:59 +09:00
syuilo abd2deae79 fix(frontend): make MkObjectView selectable 2026-08-18 20:04:56 +09:00
syuilo 9d24906786 New Crowdin updates (#17848)
* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Catalan)

[ci skip]

* New translations ja-jp.yml (Spanish)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (French)

[ci skip]

* New translations ja-jp.yml (French)

[ci skip]

* New translations ja-jp.yml (Chinese Traditional)

[ci skip]
2026-08-18 19:59:24 +09:00
72 changed files with 1748 additions and 1333 deletions
+2
View File
@@ -6,6 +6,8 @@ on:
- packages/misskey-js/**
- .github/workflows/api-misskey-js.yml
pull_request:
branches-ignore:
- master
paths:
- packages/misskey-js/**
- .github/workflows/api-misskey-js.yml
@@ -6,6 +6,8 @@ on:
- master
- develop
pull_request:
branches-ignore:
- master
jobs:
check-spdx-license-id:
+2
View File
@@ -7,6 +7,8 @@ on:
- master
- develop
pull_request:
branches-ignore:
- master
jobs:
dockle:
+2
View File
@@ -21,6 +21,8 @@ on:
- .github/workflows/lint.yml
- package.json
pull_request:
branches-ignore:
- master
paths:
- packages/backend/**
- packages/frontend/**
+2
View File
@@ -7,6 +7,8 @@ on:
- locales/**
- .github/workflows/locale.yml
pull_request:
branches-ignore:
- master
paths:
- packages/i18n/**
- locales/**
+2
View File
@@ -13,6 +13,8 @@ on:
- pnpm-lock.yaml
- .github/workflows/packages-private.yml
pull_request:
branches-ignore:
- master
paths:
- packages-private/**
- packages/shared/eslint.config.js
+2
View File
@@ -12,6 +12,8 @@ on:
- .github/workflows/test-backend.yml
- .github/misskey/test.yml
pull_request:
branches-ignore:
- master
paths:
- packages/backend/**
# for permissions
+2
View File
@@ -10,6 +10,8 @@ on:
- packages/misskey-js/**
- .github/workflows/test-federation.yml
pull_request:
branches-ignore:
- master
paths:
- packages/backend/**
- packages/misskey-js/**
+2
View File
@@ -14,6 +14,8 @@ on:
- .github/workflows/test-frontend.yml
- .github/misskey/test.yml
pull_request:
branches-ignore:
- master
paths:
- packages/frontend/**
# for permissions
+2
View File
@@ -6,6 +6,8 @@ on:
- master
- develop
pull_request:
branches-ignore:
- master
env:
NODE_ENV: production
+2
View File
@@ -9,6 +9,8 @@ on:
- packages/backend/**
- .github/workflows/validate-api-json.yml
pull_request:
branches-ignore:
- master
paths:
- packages/backend/**
- .github/workflows/validate-api-json.yml
+6 -4
View File
@@ -1,14 +1,19 @@
## 2026.8.0
### General
-
- Enhance: ノート翻訳時、CWも翻訳対象に含めるように
### Client
- Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます)
- Enhance: メディアリスト上でぼかしを解除したメディアを画像ビューワーで表示した際にぼかしを解除した状態で表示するように
- Enhance: 音声プレイヤーを画像・動画ビューワーに統合
- Enhance: パフォーマンスの向上
- Fix: 画像の表示時にBlurhashが描画されない場合があるのを修正
- Fix: モバイルでの利用時に一部のテキストが選択できない問題を修正
### Server
- Fix: 既にミュートしているスレッドに対して再度スレッドミュートを作成しようとするとサーバーエラーになる問題を修正
- Fix: 古いバージョンのMisskeyで作成されたアカウントでノートの連合が行えない場合があるのを修正
## 2026.7.0
@@ -29,9 +34,6 @@
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
### General
- Feat: 自分に表示する公開ロールとロールバッジをユーザー側で選べるように
- 必ず表示する必要があるロールを作成することもできます(既存のロールについてはすべて表示必須設定となります。必要に応じて設定を変更してください)
- 今後新規作成するロールのデフォルト値は表示必須設定なし(ユーザーが表示・非表示を選べる)となります。
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
+1
View File
@@ -1421,6 +1421,7 @@ append: "Afegeix al final"
prepend: "Afegeix al principi"
urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures"
urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà."
pixelatedZoom: "Mode de zoom d'art pixelat"
_imageEditing:
_vars:
caption: "Títol de l'arxiu"
+1
View File
@@ -1421,6 +1421,7 @@ append: "Añadir al final"
prepend: "Añadir al principio"
urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas"
urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura."
pixelatedZoom: "Modo de ampliación para pixel art"
_imageEditing:
_vars:
caption: "Título del archivo"
+32 -3
View File
@@ -174,7 +174,7 @@ emojiUrl: "پیوند شکلک"
addEmoji: "افزودن شکلک"
settingGuide: "تنظیمات پیشنهادی"
cacheRemoteFiles: "کش کردن پرونده های خارجی"
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش تصاویر را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش پرونده ها را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
youCanCleanRemoteFilesCache: "شما می توانید حافظه پنهان (کش) را با کلیک بر دکمه 🗑️ در پایین صفحه مدیریت پرونده ها حذف کنید."
cacheRemoteSensitiveFiles: "کش کردن پرونده های حساس سرور های خارجی"
cacheRemoteSensitiveFilesDescription: "با غیر فعال کردن این قابلیت پرونده هایی که علامت حساس خورده اند در سرور های خارجی به جای اینکه در حافظه پنهان ذخیره شوند، مستقیما به ان پیوند داده می شوند."
@@ -200,23 +200,47 @@ removeWallpaper: "حذف تصویر پس‌زمینه"
searchWith: "جستجو: {q}"
youHaveNoLists: "شما فهرستی ندارید"
followConfirm: "آیا می خواهید {name} را دنبال کنید؟"
proxyAccount: "حساب وکیل"
proxyAccount: "حساب نماینده (پروکسی)"
host: "میزبان"
selectSelf: "انتخاب خودت"
selectUser: "انتخاب کاربر"
recipient: "گیرنده"
annotation: "حاشیه نویسی"
federation: "شبکه‌اجتماعی‌غیرمتمرکز"
instances: "سرور"
registeredAt: "ثبت شده در"
latestRequestReceivedAt: "آخرین درخواست دریافت شده"
latestStatus: "جدیدترین وضعیت"
storageUsage: "حافظه مصرفی"
charts: "نمودارها"
perHour: "در ساعت"
perHour: "هر ۱ ساعت"
perDay: "هر ۱ روز"
stopActivityDelivery: "جلوگیری از ارائه فعالیت ها"
blockThisInstance: "این سرور را مسدود کن"
silenceThisInstance: "این سرور را بی‌صدا کن"
mediaSilenceThisInstance: "محتوای چندرسانه ای این سرور را بی‌صدا کن"
operations: "عملیات ها"
software: "نرم‌افزار"
softwareName: "نام نرم‌افزار"
version: "نگارش"
metadata: "متادیتا"
withNFiles: "{n} پرونده"
monitor: "رصدگر"
jobQueue: "صف‌کار"
cpuAndMemory: "پردازنده و حافظه رم"
network: "شبکه"
disk: "حافظه ذخیره"
instanceInfo: "اطلاعات سرور"
statistics: "آمار"
clearQueue: "پاکسازی صف"
clearQueueConfirmTitle: "آیا می خواهید این صف را پاک کنید؟"
clearQueueConfirmText: "هر یادداشت تحویل داده نشده در شبکه اجتماعی غیرمتمرکز منتشر نخواهد شد. معمولا این عملیات لازم نیست."
clearCachedFiles: "پاک کردن حافظه پنهان"
clearCachedFilesConfirm: "آیا می خواهید که تمام پرونده هایی که از سرور های در حافظه پنهان دریافت کردید حذف شود."
blockedInstances: "سرور های مسدود شده"
blockedInstancesDescription: "نام سرور هایی که می خواهید مسدود کنید را فهرست کنید و با کلید اینتر (هر سرور در اول خط جدید) از هم جدا کنید. سرور های فهرست شده دیگه قابلیت ارتباط گیری با این سرور را ندارند."
silencedInstances: "سرور های بی‌صدا شده"
silencedInstancesDescription: "نام سرور هایی که میخواهید بی‌صدا کنید را فهرست کنید و با کلید اینتر (هر سرور در اول خط جدید) از هم جدا کنید. تمام حساب ها از سرور های لیست شده \"بی‌صدا\" در نظر گرفته می شود و همه‌ی دنبال کردن ها به کاربر های این سرور به درخواست دنبال کردن تبدیل می‌شود. این قابلیت بر روی سرور های مسدود شده تاثیری نخواهد داشت."
noCustomEmojis: "هیچ شکلکی نیست"
remove: "حذف"
pinnedNotes: "یادداشت سنجاق شده"
@@ -227,6 +251,7 @@ smtpHost: "میزبان"
smtpUser: "نام کاربری"
smtpPass: "گذرواژه"
theKeywordWhenSearchingForCustomEmoji: "این کلمه کلیدی است که هنگام جستجو در میان شکلک های سفارشی به کار گرفته می شود."
clearCache: "پاک کردن حافظه پنهان"
user: "کاربران"
searchByGoogle: "جستجو"
file: "پرونده ها"
@@ -256,6 +281,8 @@ _widgets:
instanceInfo: "اطلاعات سرور"
notifications: "اعلان ها"
timeline: "نوار زمانی"
federation: "شبکه‌اجتماعی‌غیرمتمرکز"
jobQueue: "صف‌کار"
_userList:
chooseList: "انتخاب یک فهرست"
_cw:
@@ -270,6 +297,8 @@ _exportOrImport:
muteList: "سکوت"
blockingList: "مسدود کردن"
userLists: "فهرست ها"
_charts:
federation: "شبکه‌اجتماعی‌غیرمتمرکز"
_notification:
youWereFollowed: "شما دنبال شده اید"
_types:
+4
View File
@@ -1040,6 +1040,7 @@ thisPostMayBeAnnoyingHome: "Publier vers le fil principal"
thisPostMayBeAnnoyingCancel: "Annuler"
thisPostMayBeAnnoyingIgnore: "Publier quand-même"
collapseRenotes: "Réduire les renotes déjà vues"
collapseRenotesDescription: "Réduire les notes que vous avez renoté ou déjà réagi."
internalServerError: "Erreur interne du serveur"
internalServerErrorDescription: "Une erreur inattendue s'est produite sur le serveur."
copyErrorInfo: "Copier les détails de lerreur"
@@ -1283,6 +1284,7 @@ postForm: "Formulaire de publication"
information: "Informations"
inMinutes: "min"
inDays: "j"
createUserSpecifiedNote: "Créer une note directe"
widgets: "Widgets"
presets: "Préréglage"
previewingThemeRestore: "Restaurer"
@@ -1758,6 +1760,8 @@ _aboutMisskey:
patrons: "Contributeurs"
projectMembers: "Membres du projet"
_displayOfSensitiveMedia:
respect: "Cacher les médias marqués comme sensibles"
ignore: "Afficher les médias marqués comme sensibles"
force: "Masquer tous les médias"
_instanceTicker:
none: "Cacher "
+1 -9
View File
@@ -1040,6 +1040,7 @@ caption: "キャプション"
loggedInAsBot: "Botアカウントでログイン中"
tools: "ツール"
cannotLoad: "読み込めません"
cannotPreview: "プレビューできません"
numberOfProfileView: "プロフィール表示回数"
like: "いいね!"
unlike: "いいねを解除"
@@ -2084,8 +2085,6 @@ _role:
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
@@ -3248,13 +3247,6 @@ _gridComponent:
patternNotMatch: "この値は{pattern}のパターンに一致しません"
notUnique: "この値は一意である必要があります"
_roleDisplay:
title: "ロールの表示/非表示"
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
displayToggle: "ロール/ロールバッジを表示する"
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"
_roleSelectDialog:
notSelected: "選択されていません"
+1
View File
@@ -1421,6 +1421,7 @@ append: "加到最后"
prepend: "加到最前"
urlPreviewSensitiveList: "限制显示缩略图的 URL"
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
pixelatedZoom: "像素画放大模式"
_imageEditing:
_vars:
caption: "文件标题"
+1
View File
@@ -1421,6 +1421,7 @@ append: "加在最後"
prepend: "加在前面"
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
pixelatedZoom: "像素藝術縮放模式"
_imageEditing:
_vars:
caption: "檔案標題"
@@ -1,21 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RoleDisplayVisibility1783059479536 {
name = 'RoleDisplayVisibility1783059479536';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`);
// 既存のロールについてはすべて強制表示とする(新規作成分についてはfalse)
await queryRunner.query(`UPDATE "role" SET "isPublicDisplayRequired" = true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`);
}
}
-1
View File
@@ -676,7 +676,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
target: values.target,
condFormula: values.condFormula,
isPublic: values.isPublic,
isPublicDisplayRequired: values.isPublicDisplayRequired ?? false,
isAdministrator: values.isAdministrator,
isModerator: values.isModerator,
isExplorable: values.isExplorable,
@@ -5,6 +5,7 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as nodeCrypto from 'crypto';
import type { MiUser } from '@/models/User.js';
import type { UserKeypairsRepository } from '@/models/_.js';
import { RedisKVCache } from '@/misc/cache.js';
@@ -23,10 +24,10 @@ export class UserKeypairService implements OnApplicationShutdown {
@Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository,
) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair:v2', {
lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: 1000 * 60 * 60, // 1h
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
fetcher: (key) => this.fetcher(key),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
});
@@ -46,4 +47,18 @@ export class UserKeypairService implements OnApplicationShutdown {
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
@bindThis
public async fetcher(userId: MiUser['id']): Promise<MiUserKeypair> {
const keyPair = await this.userKeypairsRepository.findOneByOrFail({ userId });
// migrate PKCS#1 => PKCS#8. legacy misskey generated PKCS#1 but slacc only accepts PKCS#8
if (keyPair.privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
const pkcs8Key = nodeCrypto.createPrivateKey({ key: keyPair.privateKey, format: 'pem', type: 'pkcs1' }).export({ format: 'pem', type: 'pkcs8' });
keyPair.privateKey = pkcs8Key;
void this.userKeypairsRepository.update(userId, { privateKey: pkcs8Key });
}
return keyPair;
}
}
@@ -55,7 +55,6 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
makeNotesHiddenBefore: null,
chatScope: 'mutual',
emojis: [],
hiddenRoleIds: [],
score: 0,
host: null,
inbox: null,
@@ -64,7 +64,6 @@ export class RoleEntityService {
target: role.target,
condFormula: role.condFormula,
isPublic: role.isPublic,
isPublicDisplayRequired: role.isPublicDisplayRequired,
isAdministrator: role.isAdministrator,
isModerator: role.isModerator,
isExplorable: role.isExplorable,
@@ -84,32 +83,5 @@ export class RoleEntityService {
) {
return Promise.all(roles.map(x => this.pack(x, me)));
}
@bindThis
public async packLite(
src: MiRole['id'] | MiRole,
): Promise<Packed<'RoleLite'>> {
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
return {
id: role.id,
name: role.name,
color: role.color,
iconUrl: role.iconUrl,
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
asBadge: role.asBadge,
isPublicDisplayRequired: role.isPublicDisplayRequired,
isExplorable: role.isExplorable,
displayOrder: role.displayOrder,
};
}
@bindThis
public packLiteMany(
roles: (MiRole | MiRole['id'])[],
) {
return Promise.all(roles.map(x => this.packLite(x)));
}
}
@@ -14,7 +14,6 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { MiRole } from '@/models/Role.js';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import {
birthdaySchema,
@@ -52,7 +51,6 @@ import { ChatService } from '@/core/ChatService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
import type { RoleEntityService } from './RoleEntityService.js';
import { toArray } from '@/misc/prelude/array.js';
const Ajv = _Ajv.default;
@@ -90,7 +88,6 @@ export class UserEntityService implements OnModuleInit {
private apPersonService: ApPersonService;
private noteEntityService: NoteEntityService;
private pageEntityService: PageEntityService;
private roleEntityService: RoleEntityService;
private customEmojiService: CustomEmojiService;
private announcementService: AnnouncementService;
private roleService: RoleService;
@@ -147,7 +144,6 @@ export class UserEntityService implements OnModuleInit {
this.apPersonService = this.moduleRef.get('ApPersonService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.pageEntityService = this.moduleRef.get('PageEntityService');
this.roleEntityService = this.moduleRef.get('RoleEntityService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.announcementService = this.moduleRef.get('AnnouncementService');
this.roleService = this.moduleRef.get('RoleService');
@@ -406,30 +402,6 @@ export class UserEntityService implements OnModuleInit {
return `${this.config.url}/users/${userId}`;
}
@bindThis
private prepareRoles<T extends Pick<MiRole, 'id' | 'isPublic' | 'displayOrder' | 'isPublicDisplayRequired'>>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] {
const hiddenRoleIds = new Set(user.hiddenRoleIds);
return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder);
}
@bindThis
private sanitizeHiddenRoleIds(roles: Pick<MiRole, 'id' | 'isPublic' | 'isPublicDisplayRequired'>[], user: MiUser): MiRole['id'][] {
const hideableRoleIds = new Set(roles
.filter(role => role.isPublic && !role.isPublicDisplayRequired)
.map(role => role.id));
const sanitizedRoleIds: MiRole['id'][] = [];
const seenRoleIds = new Set<MiRole['id']>();
for (const roleId of user.hiddenRoleIds) {
if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue;
sanitizedRoleIds.push(roleId);
seenRoleIds.add(roleId);
}
return sanitizedRoleIds;
}
public async pack<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>(
src: MiUser['id'] | MiUser,
me?: { id: MiUser['id']; } | null | undefined,
@@ -453,7 +425,6 @@ export class UserEntityService implements OnModuleInit {
const meId = me ? me.id : null;
const isMe = meId === user.id;
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null;
const profile = isDetailed
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
@@ -543,9 +514,10 @@ export class UserEntityService implements OnModuleInit {
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator)
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
.filter((r) => r.isPublic || iAmModerator)
.sort((a, b) => b.displayOrder - a.displayOrder)
.map((r) => ({
id: r.id,
name: r.name,
iconUrl: r.iconUrl,
displayOrder: r.displayOrder,
@@ -588,7 +560,16 @@ export class UserEntityService implements OnModuleInit {
followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope,
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))),
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
color: role.color,
iconUrl: role.iconUrl,
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
displayOrder: role.displayOrder,
}))),
memo: memo,
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}),
@@ -617,7 +598,6 @@ export class UserEntityService implements OnModuleInit {
preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)),
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: false, // 後方互換性のため
-5
View File
@@ -227,11 +227,6 @@ export class MiRole {
})
public isPublic: boolean;
@Column('boolean', {
default: false,
})
public isPublicDisplayRequired: boolean;
// trueの場合ユーザー名の横にバッジとして表示
@Column('boolean', {
default: false,
-6
View File
@@ -6,7 +6,6 @@
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiDriveFile } from './DriveFile.js';
import type { MiRole } from './Role.js';
@Entity('user')
@Index(['usernameLower', 'host'], { unique: true })
@@ -230,11 +229,6 @@ export class MiUser {
})
public emojis: string[];
@Column('varchar', {
length: 32, array: true, default: '{}',
})
public hiddenRoleIds: MiRole['id'][];
// チャットを許可する相手
// everyone: 誰からでも
// followers: フォロワーのみ
@@ -369,21 +369,6 @@ export const packedRoleLiteSchema = {
optional: false, nullable: false,
example: false,
},
asBadge: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isPublicDisplayRequired: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
displayOrder: {
type: 'integer',
optional: false, nullable: false,
@@ -427,11 +412,6 @@ export const packedRoleSchema = {
optional: false, nullable: false,
example: false,
},
isPublicDisplayRequired: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
@@ -176,11 +176,6 @@ export const packedUserLiteSchema = {
type: 'object',
nullable: false, optional: false,
properties: {
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
name: {
type: 'string',
nullable: false, optional: false,
@@ -516,15 +511,6 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
hiddenRoleIds: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
},
twoFactorBackupCodesStock: {
type: 'string',
enum: ['full', 'partial', 'none'],
@@ -32,7 +32,6 @@ export const paramDef = {
target: { type: 'string', enum: ['manual', 'conditional'] },
condFormula: { type: 'object' },
isPublic: { type: 'boolean' },
isPublicDisplayRequired: { type: 'boolean', default: false },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
@@ -37,7 +37,6 @@ export const paramDef = {
target: { type: 'string', enum: ['manual', 'conditional'] },
condFormula: { type: 'object' },
isPublic: { type: 'boolean' },
isPublicDisplayRequired: { type: 'boolean' },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean' },
@@ -76,7 +75,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
target: ps.target,
condFormula: ps.condFormula,
isPublic: ps.isPublic,
isPublicDisplayRequired: ps.isPublicDisplayRequired,
isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable,
@@ -191,12 +191,6 @@ export const paramDef = {
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
hiddenRoleIds: {
type: 'array',
maxItems: 256,
uniqueItems: true,
items: { type: 'string', format: 'misskey:id' },
},
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: muteWords,
hardMutedWords: muteWords,
@@ -299,20 +293,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
if (ps.hiddenRoleIds !== undefined) {
const roles = await this.roleService.getUserRoles(user.id);
const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id));
const hiddenRoleIds: string[] = [];
const seenRoleIds = new Set<string>();
for (const roleId of ps.hiddenRoleIds) {
if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue;
seenRoleIds.add(roleId);
hiddenRoleIds.push(roleId);
}
updates.hiddenRoleIds = hiddenRoleIds;
}
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
const count = (arr: (string[] | string)[]) => {
@@ -10,9 +10,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
@@ -83,7 +83,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}
if (note.text == null) {
let text = note.text ?? '';
if (note.cw != null) {
text = `${note.cw}\n-----\n${text}`;
}
if (text.trim() === '') {
return;
}
@@ -95,7 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
const params = new URLSearchParams();
params.append('text', note.text);
params.append('text', text);
params.append('target_lang', targetLang);
const endpoint = this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
-38
View File
@@ -128,7 +128,6 @@ describe('ユーザー', () => {
preventAiLearning: user.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hiddenRoleIds: user.hiddenRoleIds ?? [],
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
@@ -661,16 +660,12 @@ describe('ユーザー', () => {
description: rolePublic.description,
isModerator: rolePublic.isModerator,
isAdministrator: rolePublic.isAdministrator,
asBadge: rolePublic.asBadge,
isPublicDisplayRequired: rolePublic.isPublicDisplayRequired,
isExplorable: rolePublic.isExplorable,
displayOrder: rolePublic.displayOrder,
}]);
});
test('を取得することができ、バッヂロールがセットされていること', async () => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
assert.deepStrictEqual(response.badgeRoles, [{
id: roleBadge.id,
name: roleBadge.name,
iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder,
@@ -683,42 +678,9 @@ describe('ユーザー', () => {
description: roleBadge.description,
isModerator: roleBadge.isModerator,
isAdministrator: roleBadge.isAdministrator,
asBadge: roleBadge.asBadge,
isPublicDisplayRequired: roleBadge.isPublicDisplayRequired,
isExplorable: roleBadge.isExplorable,
displayOrder: roleBadge.displayOrder,
}]);
});
test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => {
const user = await signup({ username: 'userHiddenRoles' });
const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' });
const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' });
const privateRole = await role(root, { isPublic: false, name: 'Private Role' });
const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' });
const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' });
await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root);
const response = await successfulApiCall({
endpoint: 'i/update',
parameters: {
hiddenRoleIds: [
secondVisibleRole.id,
'unknownroleid',
unassignedRole.id,
privateRole.id,
forcedRole.id,
visibleRole.id,
],
},
user,
});
assert.deepStrictEqual(response.hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]);
});
test('をID指定のリスト形式で取得することができる(空)', async () => {
const parameters = { userIds: [] };
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
@@ -14,10 +14,7 @@ import { genAidx } from '@/misc/id/aidx.js';
import {
BlockingsRepository,
FollowingsRepository, FollowRequestsRepository,
MiRole,
MiUserProfile, MutingsRepository, RenoteMutingsRepository,
RoleAssignmentsRepository,
RolesRepository,
UserMemoRepository,
UserProfilesRepository,
UsersRepository,
@@ -30,7 +27,6 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { RoleService } from '@/core/RoleService.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -71,13 +67,6 @@ describe('UserEntityService', () => {
let blockingRepository: BlockingsRepository;
let mutingRepository: MutingsRepository;
let renoteMutingsRepository: RenoteMutingsRepository;
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
let roleService: RoleService;
type RoleServiceCacheController = {
rolesCache: { delete(): void };
roleAssignmentByUserIdCache: { delete(userId: MiUser['id']): void };
};
async function createUser(userData: Partial<MiUser> = {}, profileData: Partial<MiUserProfile> = {}) {
const un = secureRndstr(16);
@@ -98,32 +87,6 @@ describe('UserEntityService', () => {
return user;
}
async function createRole(roleData: Partial<MiRole> = {}) {
const role = await rolesRepository
.insert({
id: genAidx(Date.now()),
updatedAt: new Date(),
lastUsedAt: new Date(),
name: '',
description: '',
...roleData,
})
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
(roleService as unknown as RoleServiceCacheController).rolesCache.delete();
return role;
}
async function assignRole(user: MiUser, role: MiRole) {
await roleAssignmentsRepository.insert({
id: genAidx(Date.now()),
userId: user.id,
roleId: role.id,
});
(roleService as unknown as RoleServiceCacheController).roleAssignmentByUserIdCache.delete(user.id);
}
async function memo(writer: MiUser, target: MiUser, memo: string) {
await userMemosRepository.insert({
id: genAidx(Date.now()),
@@ -183,7 +146,6 @@ describe('UserEntityService', () => {
ApPersonService,
NoteEntityService,
PageEntityService,
RoleEntityService,
CustomEmojiService,
AnnouncementService,
RoleService,
@@ -234,9 +196,6 @@ describe('UserEntityService', () => {
blockingRepository = app.get<BlockingsRepository>(DI.blockingsRepository);
mutingRepository = app.get<MutingsRepository>(DI.mutingsRepository);
renoteMutingsRepository = app.get<RenoteMutingsRepository>(DI.renoteMutingsRepository);
rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
roleService = app.get<RoleService>(RoleService);
});
afterAll(async () => {
@@ -290,60 +249,6 @@ describe('UserEntityService', () => {
expect(actual.achievements).toEqual(achievements);
});
test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => {
const me = await createUser();
const visibleRole = await createRole({ name: 'visible', isPublic: true });
const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true });
const privateRole = await createRole({ name: 'private', isPublic: false });
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true });
const unassignedRole = await createRole({ name: 'unassigned', isPublic: true });
await assignRole(me, visibleRole);
await assignRole(me, secondVisibleRole);
await assignRole(me, privateRole);
await assignRole(me, forcedRole);
await usersRepository.update(me.id, {
hiddenRoleIds: [
secondVisibleRole.id,
'unknownroleid',
unassignedRole.id,
privateRole.id,
forcedRole.id,
visibleRole.id,
secondVisibleRole.id,
],
});
const updatedMe = await usersRepository.findOneByOrFail({ id: me.id });
const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' });
expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]);
});
test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => {
const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]);
const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 });
const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 });
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 });
const moderatorRole = await createRole({ name: 'moderator', isModerator: true });
await assignRole(target, hiddenRole);
await assignRole(target, visibleRole);
await assignRole(target, forcedRole);
await assignRole(moderator, moderatorRole);
await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] });
const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id });
const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' });
const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' });
expect(normalView.roles.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
expect(normalView.badgeRoles?.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
expect(normalView.badgeRoles?.every(role => typeof role.id === 'string')).toBe(true);
expect(moderatorView.roles.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
expect(moderatorView.badgeRoles?.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
});
test('alsoKnownAs as string does not throw', async () => {
const me = await createUser();
const who = await createUser();
-2
View File
@@ -357,7 +357,6 @@ export function role(params: {
isPublic?: boolean,
isExplorable?: boolean,
asBadge?: boolean,
isPublicDisplayRequired?: boolean,
canEditMembersByModerator?: boolean,
usersCount?: number,
}, seed?: string): entities.Role {
@@ -381,7 +380,6 @@ export function role(params: {
isPublic: params.isPublic ?? true,
isExplorable: params.isExplorable ?? true,
asBadge: params.asBadge ?? true,
isPublicDisplayRequired: params.isPublicDisplayRequired ?? false,
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
usersCount: params.usersCount ?? 10,
preserveAssignmentOnMoveAccount: false,
@@ -0,0 +1,676 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<!-- keyを変えて要素ごと作り直すことでAudioContextに繋いだ要素を確実に切り離す (onLoadErrorを参照) -->
<audio
:key="audioElKey"
ref="audioEl"
preload="metadata"
:crossorigin="crossOriginMode === 'anonymous' ? 'anonymous' : undefined"
:src="content.url"
@loadedmetadata="onLoadedMetadata"
@error="onLoadError"
></audio>
<canvas
ref="canvasEl"
width="1600"
height="900"
data-gallery-click-action="media"
:class="$style.visualizer"
v-bind="$attrs"
></canvas>
<div v-if="!isPlaying" :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef, shallowRef, ref, computed, watch, onBeforeUnmount } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { themeManager } from '@/theme.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import tinycolor from 'tinycolor2';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
import type { Content } from '@/components/MkLightbox.item.vue';
// ()
defineOptions({
inheritAttrs: false,
});
const props = defineProps<{
content: Content;
user?: Misskey.entities.User | null; // DriveFileusernull使
isPlaying: boolean;
volume: number;
}>();
const emit = defineEmits<{
(ev: 'loadedmetadata'): void;
}>();
const audioEl = useTemplateRef('audioEl');
const canvasEl = useTemplateRef('canvasEl');
const canvasCtx = computed(() => canvasEl.value?.getContext('2d') ?? null);
const fileUser = computed(() => props.content.file?.user ?? props.user);
//#region
// bin FFT 使 (48kHz bin 5.9Hz)
const FFT_SIZE = 8192;
const BIN_COUNT = FFT_SIZE / 2;
/** 波形の制御点数 (片側)。実際の輪郭はこれを左右にミラーした 2 倍の点で構成される */
const BAND_COUNT = 36;
/** スペクトルとして取り出す周波数レンジ。波形もビート判定もこの低域だけを見る */
const MIN_FREQ = 30;
const MAX_FREQ = 300;
/** 波形の立ち上がり / 立ち下がりの時定数 (s) */
const WAVE_ATTACK_TAU = 0.01;
const WAVE_RELEASE_TAU = 0.015;
/** 正規化後にかけるコントラスト強調の指数 大きいほど小さい山が引っ込み、大きい山との差が開く */
const CONTRAST_EXPONENT = 1.9;
/** 基準半径に対する波形の振れ幅 */
const WAVE_GAIN = 1.05;
/** Catmull-Rom スプラインの張り具合 大きいほど丸く、0 に近いほど折れ線に近づく */
const SPLINE_TENSION = 0.9;
/** 全体の音圧に追従する緩やかな拡大縮小の時定数 (s) と最大量 */
const LEVEL_TAU = 0.3;
const LEVEL_SCALE = 0.1;
/** ビート検出用のエンベロープ / その移動平均の時定数 (s) */
const BEAT_ATTACK_TAU = 0.012;
const BEAT_FALL_TAU = 0.11;
const BEAT_BASELINE_TAU = 0.7;
/** 移動平均からの超過分をビートとみなす際の増幅率と、減衰の時定数 (s) */
const BEAT_SENSITIVITY = 3;
const BEAT_RELEASE_TAU = 0.18;
/** ビートに合わせて瞬間的に拡大する量 */
const BEAT_SCALE = 0.16;
/** キャンバス短辺の半分に対する、無音時の波形半径の比 (アバターの直径もこれに一致する) */
const BASE_RADIUS_RATIO = 0.36;
//#endregion
//
const motionDamp = prefer.s.animation ? 1 : 0.3;
//#region
const freqArray = new Uint8Array(BIN_COUNT);
/** 帯域ごとに参照する bin の範囲 */
const bandStart = new Uint16Array(BAND_COUNT);
const bandEnd = new Uint16Array(BAND_COUNT);
/** 帯域ごとの生の振幅 (フレーム内の作業用) */
const rawLevels = new Float32Array(BAND_COUNT);
/** 帯域ごとの平滑化済み振幅 (0-1) */
const levels = new Float32Array(BAND_COUNT);
/** 輪郭の頂点座標 */
const pointsX = new Float64Array(BAND_COUNT * 2);
const pointsY = new Float64Array(BAND_COUNT * 2);
/** 自動レンジ調整が追跡している下限 / 上限。低域だけを見るので全帯域で 1 つのレンジを共有する */
let rangeFloor = 0;
let rangeCeil = 0;
let levelEnv = 0;
let energyEnv = 0;
let energyBaseline = 0;
let beatEnv = 0;
function setupBands(sampleRate: number) {
const ratio = MAX_FREQ / MIN_FREQ;
for (let i = 0; i < BAND_COUNT; i++) {
const lowFreq = MIN_FREQ * Math.pow(ratio, i / BAND_COUNT);
const highFreq = MIN_FREQ * Math.pow(ratio, (i + 1) / BAND_COUNT);
const toBin = (freq: number) => freq * FFT_SIZE / sampleRate;
const start = Math.min(Math.max(Math.floor(toBin(lowFreq)), 0), BIN_COUNT - 1);
const end = Math.min(Math.max(Math.ceil(toBin(highFreq)), start + 1), BIN_COUNT);
bandStart[i] = start;
bandEnd[i] = end;
}
}
function resetAnalysis() {
levels.fill(0);
rangeFloor = 0;
rangeCeil = 0;
levelEnv = 0;
energyEnv = 0;
energyBaseline = 0;
beatEnv = 0;
}
/**
* 指数平滑フレームレートに依存しないよう経過時間から係数を求める
* @param tau 目標値の約 63% まで近づくのにかかる秒数
*/
function approach(current: number, target: number, tau: number, dt: number) {
return current + (target - current) * (1 - Math.exp(-dt / tau));
}
/** 下限が下がる / 上がるときの時定数 (s) 上げ側を遅くして、鳴っている間に基準が持ち上がらないようにする */
const RANGE_FLOOR_FALL_TAU = 0.25;
const RANGE_FLOOR_RISE_TAU = 4;
/** 上限が下がるときの時定数 (s) 上がるときは即時 */
const RANGE_CEIL_FALL_TAU = 1.4;
/** レンジ幅の下限。これ以下に潰れたときに無理な増幅をしない */
const MIN_RANGE_SPAN = 0.14;
/**
* フレーム内の最小 / 最大値から正規化に使うレンジを更新して幅を返す (自動レンジ調整)
* 音源ごとの音量差を吸収しつつ下限をゆっくり上限を即時に追従させて短時間のダイナミクスは残す
*/
function updateRange(frameMin: number, framePeak: number, dt: number) {
rangeFloor = approach(rangeFloor, frameMin, frameMin < rangeFloor ? RANGE_FLOOR_FALL_TAU : RANGE_FLOOR_RISE_TAU, dt);
// +
rangeCeil = framePeak > rangeCeil ? framePeak : approach(rangeCeil, Math.max(framePeak, rangeFloor + MIN_RANGE_SPAN), RANGE_CEIL_FALL_TAU, dt);
return Math.max(rangeCeil - rangeFloor, MIN_RANGE_SPAN);
}
//#endregion
const defaultBgColor = themeManager.currentCompiledTheme?.accent ?? '#aaa';
const accentColorHue = computed(() => tinycolor(fileUser.value?.avatarBlurhash ? extractAvgColorFromBlurhash(fileUser.value.avatarBlurhash) ?? defaultBgColor : defaultBgColor).toHsl().h);
const bgColor = computed(() => {
let targetLightness, targetSaturation;
if (store.r.darkMode.value) {
targetLightness = 0.1;
targetSaturation = 0.8;
} else {
targetLightness = 0.9;
targetSaturation = 0.9;
}
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
});
const fgColor = computed(() => {
let targetLightness, targetSaturation;
if (store.r.darkMode.value) {
targetLightness = 0.25;
targetSaturation = 0.8;
} else {
targetLightness = 0.7;
targetSaturation = 0.7;
}
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
});
const messageColor = computed(() => {
let targetLightness, targetSaturation;
if (store.r.darkMode.value) {
targetLightness = 0.75;
targetSaturation = 0.4;
} else {
targetLightness = 0.25;
targetSaturation = 0.4;
}
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
});
// (watch)
const avatarImage = shallowRef<HTMLImageElement | null>(null);
//#region audio graph
let audioCtx: AudioContext | null = null;
let audioSource: MediaElementAudioSourceNode | null = null;
let analyserNode: AnalyserNode | null = null;
let gainNode: GainNode | null = null;
let abortController: AbortController | null = null;
/** 解析用のオーディオグラフを組めたかどうか。組めなかった場合は波形の代わりに文言を出す */
let isVisualizerAvailable = true;
// AnalyserNodeCORS
// createMediaElementSource()
//
function isSameOrigin(url: string) {
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch {
return false;
}
}
/** 同一オリジンの音源はCORS属性なしでも解析できる */
const isSameOriginContent = computed(() => isSameOrigin(props.content.url));
/** CORS付きでの読み込みに失敗した音源 */
const corsFailedUrl = ref<string | null>(null);
/** フォールバックで要素を作り直した後、再生を再開すべき音源 */
let resumeAfterReloadUrl: string | null = null;
/** `anonymous`: CORS付きで読み込む (解析できる) / `none`: CORS無しで読み込む (同一オリジンでない場合は再生のみ) */
const crossOriginMode = computed<'anonymous' | 'none'>(() => (isSameOriginContent.value || corsFailedUrl.value === props.content.url) ? 'none' : 'anonymous');
const canUseAudioGraph = computed(() => isSameOriginContent.value || crossOriginMode.value === 'anonymous');
// <audio>
// 2 createMediaElementSource()
//
const audioElKey = computed(() => `${crossOriginMode.value}\n${props.content.url}`);
/** 現在の要素でメタデータまで到達できたか。到達していればCORSのチェックは通過している */
let hasLoadedMetadata = false;
/** 現在の要素に対して再生が要求されたか */
let isPlayRequested = false;
function onLoadedMetadata() {
hasLoadedMetadata = true;
emit('loadedmetadata');
}
function onLoadError() {
// CORS crossorigin
// MediaErrorCORS
// ()
// CORS
//
if (crossOriginMode.value !== 'anonymous' || hasLoadedMetadata) return;
// 0
resumeAfterReloadUrl = isPlayRequested ? props.content.url : null;
corsFailedUrl.value = props.content.url;
}
let visualizerTickFrameId: number | null = null;
let lastTickTimestamp = 0;
function resumeAudioCtx() {
if (audioCtx == null || audioCtx.state !== 'suspended') return;
audioCtx.resume().catch(err => {
console.error('Failed to resume AudioContext:', err);
});
}
function visualizerTick(timestamp: number) {
// rAF dt 0.1s
const dt = lastTickTimestamp === 0 ? 1 / 60 : Math.min((timestamp - lastTickTimestamp) / 1000, 0.1);
lastTickTimestamp = timestamp;
draw(dt);
visualizerTickFrameId = window.requestAnimationFrame(visualizerTick);
}
function startVisualizerTick() {
if (visualizerTickFrameId != null) return;
lastTickTimestamp = 0;
visualizerTickFrameId = window.requestAnimationFrame(visualizerTick);
}
function stopVisualizerTick() {
if (visualizerTickFrameId == null) return;
window.cancelAnimationFrame(visualizerTickFrameId);
visualizerTickFrameId = null;
lastTickTimestamp = 0;
}
function setPlaying(playing: boolean) {
if (playing && isVisualizerAvailable) {
startVisualizerTick();
} else {
// ()
stopVisualizerTick();
}
}
/**
* 解析用のオーディオグラフを組む組めなかった場合は false を返す
* (AudioContext を作れない環境や要素が既に別の AudioContext に接続済みの場合など)
*/
function setupAudioGraph(el: HTMLAudioElement) {
try {
audioCtx = new AudioContext();
analyserNode = audioCtx.createAnalyser();
analyserNode.fftSize = FFT_SIZE;
// FFT ( 170ms) analyse()
analyserNode.smoothingTimeConstant = 0.3;
// -100-30dB 0-255 使
analyserNode.minDecibels = -90;
analyserNode.maxDecibels = -25;
gainNode = audioCtx.createGain();
gainNode.gain.value = props.volume;
audioSource = audioCtx.createMediaElementSource(el);
// ( destination )
audioSource.connect(analyserNode);
//
audioSource.connect(gainNode).connect(audioCtx.destination);
setupBands(audioCtx.sampleRate);
return true;
} catch (err) {
console.error('Failed to set up the audio graph for the visualizer:', err);
teardownAudioGraph();
return false;
}
}
function init() {
const el = audioEl.value;
if (el == null) return;
//
resetAnalysis();
// CORS
isVisualizerAvailable = canUseAudioGraph.value && setupAudioGraph(el);
if (isVisualizerAvailable) {
// GainNode100%
// (0muted)
el.volume = 1;
} else {
// ()
el.volume = props.volume;
}
el.muted = false;
abortController = new AbortController();
const signal = abortController.signal;
const on = (type: keyof HTMLMediaElementEventMap, listener: () => void) => {
el.addEventListener(type, listener, { signal });
};
// :
// (OS)
on('play', () => {
isPlayRequested = true;
resumeAudioCtx();
});
on('playing', () => setPlaying(true));
on('waiting', () => setPlaying(false));
on('pause', () => setPlaying(false));
on('ended', () => setPlaying(false));
on('emptied', () => setPlaying(false));
// ()
if (!el.paused) resumeAudioCtx();
setPlaying(!el.paused);
// audio
if (resumeAfterReloadUrl === props.content.url) {
resumeAfterReloadUrl = null;
el.play().catch(err => {
if (_DEV_) console.warn('Failed to play media:', err);
});
}
// ()
redrawIfStopped();
}
function teardownAudioGraph() {
audioSource?.disconnect();
audioSource = null;
analyserNode?.disconnect();
analyserNode = null;
gainNode?.disconnect();
gainNode = null;
if (audioCtx != null && audioCtx.state !== 'closed') {
audioCtx.close().catch(err => {
console.error('Failed to close AudioContext:', err);
});
}
audioCtx = null;
}
function teardown() {
abortController?.abort();
abortController = null;
stopVisualizerTick();
teardownAudioGraph();
isVisualizerAvailable = true;
hasLoadedMetadata = false;
isPlayRequested = false;
}
//#endregion
//#region
/** 無音ゲート フレーム平均がこの下限からこの幅の間で 0→1 になる */
const GATE_FLOOR = 0.02;
const GATE_RANGE = 0.08;
/**
* スペクトルを帯域ごとの振幅に落とし込みエンベロープ (波形音圧ビート) を更新する
*/
function analyse(dt: number) {
if (analyserNode == null) return;
analyserNode.getByteFrequencyData(freqArray);
//
for (let i = 0; i < BAND_COUNT; i++) {
let max = 0;
let total = 0;
for (let bin = bandStart[i]; bin < bandEnd[i]; bin++) {
const v = freqArray[bin];
total += v;
if (v > max) max = v;
}
const binCount = bandEnd[i] - bandStart[i];
//
rawLevels[i] = (max * 0.7 + (total / binCount) * 0.3) / 255;
}
//
let prev = rawLevels[0];
let sum = 0;
let frameMin = 1;
let framePeak = 0;
for (let i = 0; i < BAND_COUNT; i++) {
const current = rawLevels[i];
const next = i + 1 < BAND_COUNT ? rawLevels[i + 1] : current;
const smoothed = (prev + current * 3 + next) / 5;
rawLevels[i] = smoothed;
prev = current;
sum += smoothed;
if (smoothed < frameMin) frameMin = smoothed;
if (smoothed > framePeak) framePeak = smoothed;
}
// : 調
//
const frameMean = sum / BAND_COUNT;
const gate = Math.min(Math.max((frameMean - GATE_FLOOR) / GATE_RANGE, 0), 1);
const span = updateRange(frameMin, framePeak, dt);
for (let i = 0; i < BAND_COUNT; i++) {
const normalized = Math.min(Math.max((rawLevels[i] - rangeFloor) / span, 0), 1);
const target = Math.pow(normalized, CONTRAST_EXPONENT) * gate;
levels[i] = approach(levels[i], target, target > levels[i] ? WAVE_ATTACK_TAU : WAVE_RELEASE_TAU, dt);
}
//
const energy = Math.min(Math.max((frameMean - rangeFloor) / span, 0), 1) * gate;
// :
levelEnv = approach(levelEnv, energy, LEVEL_TAU, dt);
// :
energyEnv = approach(energyEnv, energy, energy > energyEnv ? BEAT_ATTACK_TAU : BEAT_FALL_TAU, dt);
energyBaseline = approach(energyBaseline, energyEnv, BEAT_BASELINE_TAU, dt);
const hit = Math.min(Math.max(energyEnv - energyBaseline, 0) * BEAT_SENSITIVITY, 1);
beatEnv = hit > beatEnv ? hit : approach(beatEnv, hit, BEAT_RELEASE_TAU, dt);
}
//#endregion
//#region
/**
* 帯域の振幅を左右対称の閉じた輪郭として塗る
*/
function fillWave(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, baseRadius: number) {
const total = BAND_COUNT * 2;
for (let k = 0; k < total; k++) {
//
const bandIndex = k < BAND_COUNT ? k : total - 1 - k;
const radius = baseRadius * (1 + levels[bandIndex] * WAVE_GAIN);
// BAND_COUNT ()
const angle = -Math.PI / 2 + Math.PI * (k + 0.5) / BAND_COUNT;
pointsX[k] = centerX + radius * Math.cos(angle);
pointsY[k] = centerY + radius * Math.sin(angle);
}
// Catmull-Rom 3 ()
ctx.beginPath();
ctx.moveTo(pointsX[0], pointsY[0]);
for (let k = 0; k < total; k++) {
const prev = (k - 1 + total) % total;
const next = (k + 1) % total;
const nextNext = (k + 2) % total;
ctx.bezierCurveTo(
pointsX[k] + (pointsX[next] - pointsX[prev]) * SPLINE_TENSION / 6,
pointsY[k] + (pointsY[next] - pointsY[prev]) * SPLINE_TENSION / 6,
pointsX[next] - (pointsX[nextNext] - pointsX[k]) * SPLINE_TENSION / 6,
pointsY[next] - (pointsY[nextNext] - pointsY[k]) * SPLINE_TENSION / 6,
pointsX[next],
pointsY[next],
);
}
ctx.closePath();
ctx.fill();
}
/**
* @param dt 前フレームからの経過時間 (s) 0 を渡すと解析を進めず現在の状態をそのまま描き直す
*/
function draw(dt: number) {
const canvas = canvasEl.value;
const ctx = canvasCtx.value;
if (canvas == null || ctx == null) return;
if (dt > 0) analyse(dt);
ctx.fillStyle = bgColor.value;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
//
// ( 0 )
const scale = 1 + (levelEnv * LEVEL_SCALE + beatEnv * BEAT_SCALE) * motionDamp;
const baseRadius = Math.min(centerX, centerY) * BASE_RADIUS_RATIO * scale;
if (isVisualizerAvailable) {
ctx.fillStyle = fgColor.value;
fillWave(ctx, centerX, centerY, baseRadius);
}
// ()
const avatar = avatarImage.value;
if (avatar != null) {
const avatarSize = baseRadius * 2;
const avatarHeight = Math.max(avatar.height * (avatarSize / avatar.width), avatarSize);
const avatarWidth = Math.max(avatar.width * (avatarSize / avatar.height), avatarSize);
ctx.save();
ctx.beginPath();
ctx.arc(centerX, centerY, avatarSize / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(avatar, centerX - avatarWidth / 2, centerY - avatarHeight / 2, avatarWidth, avatarHeight);
ctx.restore();
}
if (!isVisualizerAvailable) {
//
ctx.fillStyle = messageColor.value;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${Math.round(canvas.height * 0.055)}px ${window.getComputedStyle(canvas).fontFamily}`;
ctx.fillText(i18n.ts.cannotPreview, centerX, centerY + baseRadius + canvas.height * 0.09);
}
}
/** 停止中は描画の機会が無いので、表示内容が変わったときに自前で描き直す */
function redrawIfStopped() {
if (visualizerTickFrameId == null) draw(0);
}
//#endregion
watch(() => props.volume, (to) => {
if (gainNode != null) {
gainNode.gain.value = to;
} else if (audioEl.value != null) {
//
audioEl.value.volume = to;
}
});
watch([bgColor, fgColor], redrawIfStopped);
watch(() => fileUser.value?.avatarUrl, (avatarUrl) => {
const img = new Image();
img.addEventListener('load', () => {
avatarImage.value = img;
redrawIfStopped();
}, { once: true });
img.src = avatarUrl ?? '/static-assets/avatar.png';
}, { immediate: true });
watch(audioEl, () => {
teardown();
init();
}, { immediate: true });
onBeforeUnmount(teardown);
defineExpose({
audioEl,
});
</script>
<style module lang="scss">
.root {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.visualizer {
display: block;
user-select: none;
// 100cqw / 100cqhMkLightbox.item.vue.contentWrapper (= padding)
width: min(100cqw, calc(100cqh * 16 / 9));
height: auto;
aspect-ratio: 16 / 9;
background: var(--MI_THEME-panel);
}
.playIconWrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
place-items: center;
pointer-events: none;
}
.playIcon {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 100%;
font-size: 120%;
background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent);
scale: 1;
transition: scale 100ms ease;
}
// hovercanvas
.visualizer:hover ~ .playIconWrapper .playIcon {
scale: 1.2;
}
</style>
@@ -0,0 +1,455 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="[$style.seekbar]">
<MkMediaRange
v-model="rangePercent"
:buffer="bufferedDataRatio"
/>
</div>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<i v-if="isPlaying" class="ti ti-player-pause"></i>
<i v-else class="ti ti-player-play"></i>
</button>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
</div>
<div :class="[$style.controlsChild, $style.controlsCenter]">
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<MkMediaRange
v-model="volume"
:class="$style.volumeSeekbar"
/>
<button class="_button" :class="$style.controlButton" @click="showMenu">
<i class="ti ti-settings"></i>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { DI } from '@/di.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
const props = withDefaults(defineProps<{
/** 音量をメディア要素に適用しない(ビジュアライザー用) */
externalVolumeControl?: boolean;
}>(), {
externalVolumeControl: false,
});
const volume = defineModel<number>('volume', { required: true });
const mediaEl = inject(DI.mkLightboxItemMediaEl, shallowRef<HTMLVideoElement | HTMLAudioElement | null>(null));
const isVideo = computed(() => mediaEl.value instanceof HTMLVideoElement);
// Menu
const menuShowing = ref(false);
function showMenu(ev: PointerEvent) {
const menu: MenuItem[] = [
// TODO:
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: [{
label: '0.25x',
value: 0.25,
}, {
label: '0.5x',
value: 0.5,
}, {
label: '0.75x',
value: 0.75,
}, {
label: '1.0x',
value: 1,
}, {
label: '1.25x',
value: 1.25,
}, {
label: '1.5x',
value: 1.5,
}, {
label: '2.0x',
value: 2,
}],
},
...(window.document.pictureInPictureEnabled && isVideo.value ? [{
text: i18n.ts._mediaControls.pip,
icon: 'ti ti-picture-in-picture',
action: togglePictureInPicture,
}] : []),
];
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
onClosing: () => {
menuShowing.value = false;
},
});
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
const isPlaying = ref(false); //
const isActuallyPlaying = ref(false); // ( false)
const elapsedTimeMs = ref(0);
const durationMs = ref(0);
const rangePercent = computed({
get: () => {
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
if (mediaEl.value == null) return;
mediaEl.value.currentTime = to * durationMs.value / 1000;
},
});
const speed = ref(1);
const loop = ref(false); // TODO:
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (durationMs.value === 0) return 0;
return bufferedEnd.value / (durationMs.value / 1000);
});
// state
function togglePlayPause() {
if (!isReady.value) return;
if (isPlaying.value) {
mediaEl.value?.pause();
} else {
// reject
mediaEl.value?.play().catch(err => {
if (_DEV_) console.warn('Failed to play media:', err);
});
}
}
function togglePictureInPicture() {
// reject
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture().catch(err => {
if (_DEV_) console.warn('Failed to exit picture-in-picture:', err);
});
} else if (isVideo.value) {
(mediaEl.value as HTMLVideoElement).requestPictureInPicture().catch(err => {
if (_DEV_) console.warn('Failed to enter picture-in-picture:', err);
});
}
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
} else {
volume.value = 0;
}
}
let abortController: AbortController | null = null;
let loopObserver: MutationObserver | null = null;
// currentTime timeupdate
// 4Hz
// requestAnimationFrame
let elapsedTickFrameId: number | null = null;
function syncElapsedTime() {
if (mediaEl.value == null) return;
elapsedTimeMs.value = mediaEl.value.currentTime * 1000;
}
function elapsedTick() {
syncElapsedTime();
elapsedTickFrameId = window.requestAnimationFrame(elapsedTick);
}
function startElapsedTick() {
if (elapsedTickFrameId != null) return;
elapsedTickFrameId = window.requestAnimationFrame(elapsedTick);
}
function stopElapsedTick() {
if (elapsedTickFrameId == null) return;
window.cancelAnimationFrame(elapsedTickFrameId);
elapsedTickFrameId = null;
}
function syncDuration() {
const duration = mediaEl.value?.duration;
// NaN Infinity
durationMs.value = duration != null && Number.isFinite(duration) ? duration * 1000 : 0;
}
function syncBuffered() {
const buffered = mediaEl.value?.buffered;
if (buffered == null || buffered.length === 0) {
bufferedEnd.value = 0;
return;
}
//
let end = 0;
for (let i = 0; i < buffered.length; i++) {
if (buffered.end(i) > end) end = buffered.end(i);
}
bufferedEnd.value = end;
}
function syncReady() {
const el = mediaEl.value;
isReady.value = el != null && el.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA;
}
function init() {
const el: HTMLMediaElement | null = mediaEl.value;
if (el == null) return;
abortController = new AbortController();
const signal = abortController.signal;
const on = (type: keyof HTMLMediaElementEventMap, listener: () => void) => {
el.addEventListener(type, listener, { signal });
};
on('play', () => {
isPlaying.value = true;
oncePlayed.value = true;
startElapsedTick();
});
on('playing', () => {
isActuallyPlaying.value = true;
startElapsedTick();
});
on('waiting', () => {
isActuallyPlaying.value = false;
stopElapsedTick();
});
on('pause', () => {
isPlaying.value = false;
isActuallyPlaying.value = false;
stopElapsedTick();
});
on('ended', () => {
oncePlayed.value = false;
isPlaying.value = false;
isActuallyPlaying.value = false;
stopElapsedTick();
syncElapsedTime();
});
on('timeupdate', syncElapsedTime);
on('seeking', syncElapsedTime);
on('seeked', () => {
syncElapsedTime();
syncBuffered();
});
on('durationchange', syncDuration);
on('loadstart', syncReady);
on('canplay', syncReady);
on('canplaythrough', syncReady);
on('loadedmetadata', () => {
syncDuration();
syncBuffered();
syncReady();
});
on('progress', syncBuffered);
on('emptied', () => {
isReady.value = false;
isPlaying.value = false;
isActuallyPlaying.value = false;
oncePlayed.value = false;
stopElapsedTick();
syncDuration();
syncBuffered();
syncElapsedTime();
});
// UI
// (externalVolumeControl100%)
if (!props.externalVolumeControl) {
on('volumechange', () => {
const to = el.muted ? 0 : el.volume;
if (volume.value !== to) volume.value = to;
});
}
on('ratechange', () => {
if (speed.value !== el.playbackRate) speed.value = el.playbackRate;
});
// loop UI
loopObserver = new MutationObserver(() => {
if (loop.value !== el.loop) loop.value = el.loop;
});
loopObserver.observe(el, { attributes: true, attributeFilter: ['loop'] });
// state
// ()
syncReady();
syncDuration();
syncBuffered();
syncElapsedTime();
loop.value = el.loop;
speed.value = el.playbackRate;
isPlaying.value = !el.paused;
if (!el.paused) {
oncePlayed.value = true;
startElapsedTick();
}
if (!props.externalVolumeControl) {
el.volume = volume.value;
}
// GIF
if (isVideo.value) {
hasAudio(el).then(had => {
// teardown / init
if (signal.aborted) return;
if (!had) {
el.loop = el.muted = true;
el.play().catch(err => {
if (_DEV_) console.warn('Failed to play media:', err);
});
}
});
}
}
function teardown() {
abortController?.abort();
abortController = null;
loopObserver?.disconnect();
loopObserver = null;
stopElapsedTick();
isReady.value = false;
//
// (isPlaying / loop / speed init() )
isActuallyPlaying.value = false;
oncePlayed.value = false;
}
watch(volume, (to) => {
if (props.externalVolumeControl) return; // (Web Audio)
if (mediaEl.value == null) return;
mediaEl.value.volume = to;
mediaEl.value.muted = to === 0;
});
watch(speed, (to) => {
if (mediaEl.value == null) return;
mediaEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (mediaEl.value == null) return;
mediaEl.value.loop = to;
});
watch(mediaEl, () => {
teardown();
init();
}, { immediate: true });
onBeforeUnmount(teardown);
defineExpose({
isPlaying,
isReady,
isActuallyPlaying,
});
</script>
<style lang="scss" module>
.root {
display: grid;
grid-template-areas:
"seekbar seekbar seekbar"
"left center right";
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 4px 8px;
width: 100%;
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
}
.controlsLeft {
grid-area: left;
}
.controlsRight {
grid-area: right;
}
.controlsCenter {
grid-area: center;
justify-content: center;
}
.controlButton {
padding: 6px;
border-radius: 4px;
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&:hover {
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
}
&:focus-visible {
outline: none;
}
}
.controlsTime {
font-size: 85%;
}
.seekbar {
grid-area: seekbar;
}
</style>
@@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.hiddenText, { [$style.withBlur]: content.type === 'video' && content.thumbnailUrl != null }]">
<div :class="$style.hiddenTextWrapper">
<b v-if="content.file?.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
<b v-else style="display: block;"><i class="ti" :class="content.type === 'image' ? 'ti-photo' : 'ti-movie'"></i> {{ content.type === 'image' ? i18n.ts.image : i18n.ts.video }}</b>
<b v-else style="display: block;"><i class="ti" :class="contentHideFileIcon"></i> {{ contentHideFileText }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
@@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<video
v-else-if="content.type === 'video'"
ref="videoEl"
data-gallery-click-action="video"
data-gallery-click-action="media"
:class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
:src="content.url"
:alt="content.file?.comment ?? undefined"
@@ -90,16 +90,36 @@ SPDX-License-Identifier: AGPL-3.0-only
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
playsinline
@loadedmetadata="onVideoLoadedMetadata"
@click.stop="onVideoClick"
@click.stop="onMediaClick"
></video>
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isMediaPlaying" :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
</div>
<div v-if="content.type === 'audio' && prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.audioRoot">
<audio
ref="audioEl"
:src="content.url"
:alt="content.file?.comment ?? undefined"
:class="$style.audio"
controls
@loadedmetadata="originalContentLoaded = true"
></audio>
</div>
<XAudioVisualizer
v-else-if="content.type === 'audio' && !prefer.s.useNativeUiForVideoAudioPlayer"
ref="audioVisualizer"
:content="content"
:user="user"
:isPlaying="isMediaPlaying"
:volume="volume"
@click.stop="onMediaClick"
@loadedmetadata="originalContentLoaded = true"
/>
</template>
<div v-if="activated && (!originalContentLoaded || (content.type === 'video' && isVideoPlaying && !isVideoActuallyPlaying))" :class="$style.loading">
<div v-if="activated && (!originalContentLoaded || (isMediaControlledByMisskey && isMediaPlaying && (!isMediaReady || !isMediaActuallyPlaying)))" :class="$style.loading">
<MkLoading/>
</div>
</template>
@@ -118,8 +138,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
<div v-if="content.type === 'video' && !hide && !prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.mediaControl">
<MkVideoControl v-if="videoEl != null" ref="videoControl" :videoEl="videoEl"/>
<div v-if="isMediaControlledByMisskey && !hide" :class="$style.mediaControl">
<XControl v-if="mediaEl != null" ref="mediaControl" v-model:volume="volume" :externalVolumeControl="isVolumeHandledByVisualizer"/>
</div>
</div>
</div>
@@ -140,7 +160,7 @@ type Rect = Size & {
export type Content = {
id: string;
type: 'image' | 'video';
type: 'image' | 'video' | 'audio';
url: string;
thumbnailUrl?: string | null;
width?: number | null;
@@ -177,9 +197,10 @@ export function calculateSourceTransform({
</script>
<script lang="ts" setup>
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount } from 'vue';
import MkVideoControl from '@/components/MkVideoControl.vue';
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount, defineAsyncComponent } from 'vue';
import MkBlurhash from '@/components/MkBlurhash.vue';
import XControl from './MkLightbox.item.controls.vue';
import type XAudioVisualizer__TypeReferenceOnly from './MkLightbox.item.audio-visualizer.vue';
import XFileInfo from './MkLightbox.item.fileinfo.vue';
import type { MenuItem } from '@/types/menu.js';
import { DI } from '@/di.js';
@@ -194,10 +215,11 @@ import { getFileMenu } from '@/utility/get-file-menu.js';
const props = withDefaults(defineProps<{
content: Content;
user?: Misskey.entities.User | null; // DriveFileusernull使
activated: boolean;
initiallyOpened?: boolean;
initiallyRevealed?: boolean;
}>(), {
initiallyOpened: false,
initiallyRevealed: false,
});
const emit = defineEmits<{
@@ -208,6 +230,8 @@ const emit = defineEmits<{
(ev: 'cancelHorizontalSwipe'): void;
}>();
const XAudioVisualizer = defineAsyncComponent(() => import('./MkLightbox.item.audio-visualizer.vue'));
// model
// TODO: drivefileproperties
const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
@@ -215,19 +239,65 @@ const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
const rootEl = useTemplateRef('rootEl');
const mainEl = useTemplateRef('mainEl');
const videoEl = useTemplateRef('videoEl');
const videoControl = useTemplateRef('videoControl');
const audioEl = useTemplateRef('audioEl'); // UI
const audioVisualizer = useTemplateRef<InstanceType<typeof XAudioVisualizer__TypeReferenceOnly>>('audioVisualizer'); // UI
const mediaControl = useTemplateRef<InstanceType<typeof XControl>>('mediaControl');
provide(DI.mkLightboxItemVideoEl, videoEl);
const mediaEl = computed<HTMLVideoElement | HTMLAudioElement | null>(() => {
if (props.content.type === 'video') {
return videoEl.value;
} else if (props.content.type === 'audio') {
if (prefer.s.useNativeUiForVideoAudioPlayer) {
return audioEl.value;
} else {
return audioVisualizer.value?.audioEl ?? null;
}
} else {
return null;
}
});
provide(DI.mkLightboxItemMediaEl, mediaEl);
const originalContentLoaded = ref(false);
const thumbnailContentLoaded = ref(false);
const enableTransition = ref(false);
const infoShowing = ref(false);
const hide = ref(true);
const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
const isMediaControlledByMisskey = computed(() => ['video', 'audio'].includes(props.content.type) && !prefer.s.useNativeUiForVideoAudioPlayer);
// 使GainNode (100%)
const isVolumeHandledByVisualizer = computed(() => props.content.type === 'audio' && !prefer.s.useNativeUiForVideoAudioPlayer);
const volume = ref(0.25);
const isMediaReady = computed(() => mediaControl.value?.isReady ?? false);
const isMediaPlaying = computed(() => mediaControl.value?.isPlaying ?? false);
const isMediaActuallyPlaying = computed(() => mediaControl.value?.isActuallyPlaying ?? false);
let canOpenAnimation = false;
const contentHideFileIcon = computed(() => {
switch (props.content.type) {
case 'image':
return 'ti-photo';
case 'video':
return 'ti-movie';
case 'audio':
return 'ti-music';
default:
return '';
}
});
const contentHideFileText = computed(() => {
switch (props.content.type) {
case 'image':
return i18n.ts.image;
case 'video':
return i18n.ts.video;
case 'audio':
return i18n.ts.audio;
default:
return '';
}
});
const videoAspectRatio = ref<number | null>(
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
? props.content.width / props.content.height
@@ -244,7 +314,7 @@ function onVideoLoadedMetadata() {
}
const headerSize = 30;
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
const footerSize = isMediaControlledByMisskey.value ? 80 : 0;
const padding = deviceKind === 'smartphone' ? {
top: Math.max(0, headerSize + 10),
@@ -302,8 +372,8 @@ function shouldHideInGallery(content: Content): boolean {
const hiddenByDefault = shouldHideFileByDefault(content.file, true);
if (!hiddenByDefault) return false;
//
if (content.file.isSensitive && props.initiallyOpened) {
//
if (props.initiallyRevealed) {
return false;
}
@@ -483,7 +553,7 @@ const VELOCITY_WINDOW = 100;
let isDragging = false;
let isClick = false;
let clickAction: 'hidden' | 'video' | null = null;
let clickAction: 'hidden' | 'media' | null = null;
let lastX = 0;
let lastY = 0;
let currentPointerId: number | null = null;
@@ -527,11 +597,11 @@ function getVelocity(now: number): { x: number; y: number } {
};
}
function resolveClickAction(target: EventTarget | null): 'hidden' | 'video' | null {
function resolveClickAction(target: EventTarget | null): 'hidden' | 'media' | null {
if (!(target instanceof Element)) return null;
const action = target.closest('[data-gallery-click-action]')?.getAttribute('data-gallery-click-action');
if (action === 'hidden' || action === 'video') {
if (action === 'hidden' || action === 'media') {
return action;
}
@@ -831,8 +901,8 @@ function onClick(ev: MouseEvent) {
return;
}
if (action === 'video') {
onVideoClick();
if (action === 'media') {
onMediaClick();
return;
}
@@ -846,25 +916,49 @@ function onClick(ev: MouseEvent) {
}
}
/**
* play() は自動再生のブロックや再生が始まる前の pause() によって reject することがある
* いずれも再生ボタンが出たままになるだけで復帰不能ではないので握りつぶす
*/
function safePlay(el: HTMLMediaElement) {
el.play().catch(err => {
if (_DEV_) console.warn('Failed to play media:', err);
});
}
function playWhenAvailable() {
if (mediaEl.value != null) {
safePlay(mediaEl.value);
return;
}
// lazy-load
const watchStop = watch(mediaEl, (newMediaEl) => {
if (newMediaEl == null) return;
safePlay(newMediaEl);
watchStop();
});
}
async function onHiddenClick() {
if (hide.value) {
if (props.content.file == null || await canRevealFile(props.content.file)) {
hide.value = false;
if (props.content.type === 'video' && videoEl.value != null) {
videoEl.value.play();
if (['audio', 'video'].includes(props.content.type)) {
playWhenAvailable();
}
}
}
}
function onVideoClick() {
function onMediaClick() {
if (!prefer.s.useNativeUiForVideoAudioPlayer) {
if (videoEl.value == null) return;
if (mediaEl.value == null) return;
if (videoEl.value.paused) {
videoEl.value.play();
if (mediaEl.value.paused) {
safePlay(mediaEl.value);
} else {
videoEl.value.pause();
mediaEl.value.pause();
}
}
}
@@ -883,12 +977,16 @@ function openMenu(ev: PointerEvent) {
type: 'divider',
});
if (props.content.type === 'image') {
menu.push({
type: 'switch',
text: i18n.ts.pixelatedZoom,
icon: 'ti ti-grain',
ref: pixelatedZoom,
});
}
menu.push({
type: 'switch',
text: i18n.ts.pixelatedZoom,
icon: 'ti ti-grain',
ref: pixelatedZoom,
}, {
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
@@ -908,9 +1006,7 @@ function openMenu(ev: PointerEvent) {
}
function onActive() {
if (videoEl.value != null) {
videoEl.value.play();
}
playWhenAvailable();
}
function onDeactive() {
@@ -918,8 +1014,8 @@ function onDeactive() {
isZooming.value = false;
resetToNeutral();
}
if (videoEl.value != null && props.activated) {
videoEl.value.pause();
if (mediaEl.value != null && props.activated) {
mediaEl.value.pause();
}
}
@@ -988,6 +1084,20 @@ defineExpose({
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
}
.audioRoot {
width: 100%;
height: 100%;
margin: 0 auto;
max-width: calc(100vw - 140px);
display: flex;
align-items: center;
justify-content: center;
}
.audio {
width: 100%;
}
.loading {
position: absolute;
top: 0;
@@ -30,7 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
v-model:pixelatedZoom="pixelatedZoom"
:content="content"
:initiallyOpened="i === (props.defaultIndex ?? 0)"
:user="user"
:initiallyRevealed="props.initiallyRevealedContentIds?.includes(content.id) ?? false"
:activated="activatedIndexes.has(i)"
@close="onItemClose"
@horizontalSwipe="onHorizontalSwipe"
@@ -50,6 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, watch, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import XItem from './MkLightbox.item.vue';
import type { Content } from './MkLightbox.item.vue';
import type { Keymap } from '@/utility/hotkey.js';
@@ -61,6 +63,8 @@ import { focusTrap } from '@/utility/focus-trap.js';
const props = withDefaults(defineProps<{
defaultIndex?: number;
contents: Content[];
initiallyRevealedContentIds?: string[];
user?: Misskey.entities.User | null; // DriveFileusernull使
}>(), {
});
+154 -402
View File
@@ -6,14 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="playerEl"
v-hotkey="keymap"
tabindex="0"
:class="[
$style.audioContainer,
$style.root,
(audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop
@keydown.stop
@contextmenu.stop="onContextmenu"
>
<button v-if="hide" :class="$style.hidden" @click="reveal">
<div :class="$style.hiddenTextWrapper">
@@ -22,140 +19,84 @@ SPDX-License-Identifier: AGPL-3.0-only
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</button>
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer">
<audio
ref="audioEl"
preload="metadata"
controls
:class="$style.nativeAudio"
@keydown.prevent
>
<source :src="audio.url">
</audio>
</div>
<div v-else :class="$style.audioControls">
<audio
ref="audioEl"
preload="metadata"
@keydown.prevent="() => {}"
>
<source :src="audio.url">
</audio>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="togglePlayPause"
>
<i v-if="isPlaying" class="ti ti-player-pause"></i>
<i v-else class="ti ti-player-play"></i>
</button>
<div
v-else
v-panel
role="button"
tabindex="0"
:class="$style.audioRoot"
@click="emit('mediaClick', $event)"
@keydown.enter.prevent="emit('mediaClick', $event)"
@keydown.space.prevent="emit('mediaClick', $event)"
>
<div :class="$style.audioRootSkelton"></div>
<svg version="1.1" viewBox="0 0 2911.5 735.42" xmlns="http://www.w3.org/2000/svg" :class="$style.audioWave">
<path d="m2852.4 363.63h-21.65v8.17h21.65zm29.53 0h-21.65v8.17h21.65zm29.53 0h-21.65v8.17h21.65z"/>
<path d="m1966.5 371.8h-21.65v-8.17h21.65zm29.53-45.33h-21.65v82.48h21.65zm29.53-51.19h-21.65v184.86h21.65z"/>
<path d="m2468.6 363.63h-21.65v8.17h21.65zm-29.53-158.13h-21.65v324.42h21.65zm-29.53-85.62h-21.65v495.67h21.65z"/>
<path d="m2320.9 52.6h-21.65v630.22h21.65zm59.06 0h-21.65v630.22h21.65zm-29.53-52.6h-21.65v735.42h21.65v-735.41z"/>
<path d="m2498.1 320.48h-21.65v94.47h21.65zm59.06-200.6h-21.65v495.67h21.65zm-29.53 114.98h-21.65v265.71h21.65z"/>
<path d="m2084.7 234.86h-21.65v265.71h21.65zm29.53-89.29h-21.65v444.3h21.65zm-59.06 71.31h-21.65v301.67h21.65z"/>
<path d="m2202.8 363.63h-21.65v8.17h21.65zm-59.06-139.94h-21.65v288.05h21.65zm29.53 87.58h-21.65v112.9h21.65z"/>
<path d="m2645.7 262.99h-21.65v209.44h21.65zm-59.06-117.43h-21.65v444.3h21.65v-444.29zm29.53 149.23h-21.65v145.84h21.65z"/>
<path d="m2704.8 262.99h-21.65v209.44h21.65zm-29.53-28.13h-21.65v265.71h21.65zm59.06 59.94h-21.65v145.84h21.65z"/>
<path d="m2822.9 349.18h-21.65v37.07h21.65zm-29.53-28.7h-21.65v94.47h21.65zm-29.53-25.69h-21.65v145.84h21.65z"/>
<path d="m2261.8 171.25h-21.65v392.92h21.65zm29.53-51.37h-21.65v495.67h21.65zm-59.06 174.92h-21.65v145.84h21.65z"/>
<path d="m297.93 374.56h-22.45v-8.48h22.45zm30.61-8.48h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45z"/>
<path d="m940.74 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45z"/>
<path d="m512.2 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm-61.22-62.42h-22.44v133.32h22.44z"/>
<path d="m573.42 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm30.61-62.42h-22.44v133.32h22.44z"/>
<path d="m114.27 222.53h-22.44v295.59h22.44zm61.22 0h-22.44v295.59h22.44zm-30.61-39.7h-22.44v375h22.44v-375.01z"/>
<path d="m818.29 222.53h-22.44v295.59h22.44zm-61.22-129.47h-22.44v554.53h22.44zm30.61-36.25h-22.44v627.03h22.44z"/>
<path d="m22.44 20.55h-22.44v699.54h22.44zm30.61 36.25h-22.44v627.04h22.44v-627.03zm30.61 210.61h-22.44v205.82h22.44z"/>
<path d="m267.31 303.66h-22.44v133.32h22.44zm-61.22-36.25h-22.44v205.82h22.44zm30.61 0h-22.44v205.82h22.44z"/>
<path d="m389.75 303.66h-22.44v133.32h22.44zm30.61 0h-22.44v133.32h22.44zm30.61-36.25h-22.44v205.82h22.44z"/>
<path d="m726.46 141.39h-22.44v457.86h22.44zm-30.61 41.43h-22.44v375h22.44zm-30.61 84.59h-22.44v205.82h22.44z"/>
<path d="m910.12 222.53h-22.44v295.59h22.44zm-61.22-39.7h-22.44v375h22.44v-375.01zm30.61 84.59h-22.44v205.82h22.44z"/>
<path d="m1286.3 524.19h-19.72v-307.49h19.72zm-30.59-388.64h-19.72v469.79h19.72zm-30.59-63.76h-19.72v597.32h19.72z"/>
<path d="m1133.4 605.34h-19.72v-469.79h19.72zm30.59-533.55h-19.72v597.32h19.72zm30.59-49.85h-19.72v697.02h19.72z"/>
<path d="m1500.5 469.7h-19.72v-198.51h19.72zm61.18-198.51h-19.72v198.51h19.72zm-30.59-26.66h-19.72v251.84h19.72z"/>
<path d="m1408.7 605.34h-19.72v-469.79h19.72zm30.59-445.45h-19.72v421.1h19.72v-421.09zm30.59 141.44h-19.72v138.23h19.72z"/>
<path d="m1653.4 415.21h-19.72v-89.53h19.72zm-61.18-113.88h-19.72v138.23h19.72zm30.59 0h-19.72v138.23h19.72z"/>
<path d="m1775.8 415.21h-19.72v-89.53h19.72zm30.59-89.53h-19.72v89.53h19.72zm30.59-24.35h-19.72v138.23h19.72z"/>
<path d="m1102.8 556.65h-19.72v-372.41h19.72zm-30.59-255.32h-19.72v138.23h19.72zm-30.59 64.89h-19.72v8.45h19.72z"/>
<path d="m1347.5 415.21h-19.72v-89.53h19.72zm30.59-170.69h-19.72v251.84h19.72v-251.83zm-61.18 121.7h-19.72v8.45h19.72z"/>
<path d="m1684 374.67h-19.72v-8.45h19.72zm30.59-8.45h-19.72v8.45h19.72zm30.59 0h-19.72v8.45h19.72z"/>
<path d="m1867.6 415.21h-19.72v-89.53h19.72zm61.18-48.99h-19.72v8.45h19.72zm-30.59 0h-19.72v8.45h19.72z"/>
</svg>
<div :class="$style.audioText">
<i class="ti ti-music"></i> {{ i18n.ts.audio }}
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="() => {}"
@mousedown.prevent.stop="showMenu"
>
<i class="ti ti-settings"></i>
</button>
<div :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
</div>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<div :class="[$style.controlsChild, $style.controlsVolume]">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="toggleMute"
>
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<MkMediaRange
v-model="volume"
:class="$style.volumeSeekbar"
/>
</div>
<MkMediaRange
v-model="rangePercent"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @keydown.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @keydown.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</div>
</div>
</template>
<script lang="ts" setup>
import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import type { Keymap } from '@/utility/hotkey.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
const props = defineProps<{
audio: Misskey.entities.DriveFile;
}>();
const keymap = {
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
}
},
},
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
} as const satisfies Keymap;
// PlayerEl
function hasFocus() {
if (!playerEl.value) return false;
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
}
const playerEl = useTemplateRef('playerEl');
const audioEl = useTemplateRef('audioEl');
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent | KeyboardEvent): void;
}>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref(shouldHideFileByDefault(props.audio));
async function reveal() {
@@ -166,219 +107,30 @@ async function reveal() {
hide.value = false;
}
// Menu
const menuShowing = ref(false);
function showMenu(ev: MouseEvent) {
const menu: MenuItem[] = [
// TODO:
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: [{
label: '0.25x',
value: 0.25,
}, {
label: '0.5x',
value: 0.5,
}, {
label: '0.75x',
value: 0.75,
}, {
label: '1.0x',
value: 1,
}, {
label: '1.25x',
value: 1.25,
}, {
label: '1.5x',
value: 1.5,
}, {
label: '2.0x',
value: 2,
}],
},
{
type: 'divider',
},
];
menu.push(...getFileMenu(props.audio, (newState) => {
hide.value = newState;
}));
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
onClosing: () => {
menuShowing.value = false;
},
});
function showMenu(ev: PointerEvent) {
os.popupMenu(getFileMenu(props.audio, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
const isPlaying = ref(false);
const isActuallyPlaying = ref(false);
const elapsedTimeMs = ref(0);
const durationMs = ref(0);
const rangePercent = computed({
get: () => {
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
if (!audioEl.value) return;
audioEl.value.currentTime = to * durationMs.value / 1000;
},
});
const volume = ref(.25);
const speed = ref(1);
const loop = ref(false); // TODO:
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (!audioEl.value) return 0;
return bufferedEnd.value / audioEl.value.duration;
});
// MediaControl Events
function togglePlayPause() {
if (!isReady.value || !audioEl.value) return;
if (isPlaying.value) {
audioEl.value.pause();
isPlaying.value = false;
} else {
audioEl.value.play();
isPlaying.value = true;
oncePlayed.value = true;
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.audio, (newHide) => { hide.value = newHide; }), ev);
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
} else {
volume.value = 0;
}
}
let onceInit = false;
let mediaTickFrameId: number | null = null;
let stopAudioElWatch: () => void;
function init() {
if (onceInit) return;
onceInit = true;
stopAudioElWatch = watch(audioEl, () => {
if (audioEl.value) {
isReady.value = true;
function updateMediaTick() {
if (audioEl.value) {
try {
bufferedEnd.value = audioEl.value.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
if (audioEl.value.loop !== loop.value) {
loop.value = audioEl.value.loop;
}
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
audioEl.value.addEventListener('play', () => {
isActuallyPlaying.value = true;
});
audioEl.value.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
});
audioEl.value.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
});
durationMs.value = audioEl.value.duration * 1000;
audioEl.value.addEventListener('durationchange', () => {
if (audioEl.value) {
durationMs.value = audioEl.value.duration * 1000;
}
});
audioEl.value.volume = volume.value;
}
}, {
immediate: true,
});
}
watch(volume, (to) => {
if (audioEl.value) audioEl.value.volume = to;
});
watch(speed, (to) => {
if (audioEl.value) audioEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (audioEl.value) audioEl.value.loop = to;
});
onMounted(() => {
init();
});
onActivated(() => {
init();
});
onDeactivated(() => {
isReady.value = false;
isPlaying.value = false;
isActuallyPlaying.value = false;
elapsedTimeMs.value = 0;
durationMs.value = 0;
bufferedEnd.value = 0;
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore');
stopAudioElWatch();
onceInit = false;
if (mediaTickFrameId) {
window.cancelAnimationFrame(mediaTickFrameId);
mediaTickFrameId = null;
}
});
</script>
<style lang="scss" module>
.audioContainer {
.root {
container-type: inline-size;
position: relative;
border: .5px solid var(--MI_THEME-divider);
border-radius: var(--MI-radius);
overflow: clip;
&:focus-visible {
outline: none;
}
&:hover {
.playIcon {
scale: 1.2;
}
}
}
.sensitive {
@@ -399,6 +151,7 @@ onDeactivated(() => {
.hidden {
width: 100%;
height: 100%;
background: #000;
border: none;
outline: none;
@@ -417,86 +170,85 @@ onDeactivated(() => {
color: #fff;
}
.audioControls {
display: grid;
grid-template-areas:
"left time . volume right"
"seekbar seekbar seekbar seekbar seekbar";
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 4px 8px;
padding: 10px;
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
.controlButton {
padding: 6px;
border-radius: calc(var(--MI-radius) / 2);
font-size: 1.05rem;
&:hover {
color: var(--MI_THEME-accent);
background-color: var(--MI_THEME-accentedBg);
}
&:focus-visible {
outline: none;
}
}
}
.controlsLeft {
grid-area: left;
}
.controlsRight {
grid-area: right;
}
.controlsTime {
grid-area: time;
font-size: .9rem;
}
.controlsVolume {
grid-area: volume;
.volumeSeekbar {
display: none;
}
}
.seekbarRoot {
grid-area: seekbar;
}
@container (min-width: 500px) {
.audioControls {
grid-template-areas: "left seekbar time volume right";
grid-template-columns: auto 1fr auto auto auto;
}
.controlsVolume {
.volumeSeekbar {
max-width: 90px;
display: block;
flex-grow: 1;
}
}
}
.nativeAudioContainer {
display: flex;
align-items: center;
padding: 6px;
}
.nativeAudio {
display: block;
.audioRoot {
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
}
.audioRootSkelton {
position: relative;
width: min(100cqw, calc(100cqh * 16 / 9));
height: auto;
aspect-ratio: 16 / 9;
pointer-events: none;
}
.audioWave {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
fill: color(from var(--MI_THEME-fg) srgb r g b / 0.5);
}
.audioText {
position: absolute;
bottom: 12px;
left: 12px;
font-size: 0.85em;
user-select: none;
pointer-events: none;
}
.playIconWrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.playIcon {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 100%;
font-size: 120%;
background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent);
scale: 1;
transition: scale 100ms ease;
}
.menu {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
color: #fff;
font-size: 0.8em;
width: 28px;
height: 28px;
text-align: center;
}
.menuBottom {
border-radius: 8px 0 8px 0;
bottom: 0;
right: 0;
}
.menuTop {
border-radius: 0 8px 0 8px;
top: 0;
right: 0;
}
</style>
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
<div v-else-if="hide" :class="$style.sensitive" @click="reveal">
<div v-if="hide" :class="$style.sensitive" @click="reveal">
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
<b>{{ i18n.ts.sensitive }}</b>
<span>{{ i18n.ts.clickToShow }}</span>
@@ -27,7 +26,6 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import MkMediaAudio from '@/components/MkMediaAudio.vue';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
const props = defineProps<{
@@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { MediaComponentExposes } from '@/types/media-component.js';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import bytes from '@/filters/bytes.js';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
@@ -138,6 +139,10 @@ function showMenu(ev: PointerEvent) {
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), ev);
}
defineExpose<MediaComponentExposes>({
isRevealed: () => !hide.value,
});
</script>
<style lang="scss" module>
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
<XBanner v-for="media in medias.nonPreviewable" :key="media.id" :media="media"/>
<div v-if="count > 0" :class="$style.container">
<div
ref="gallery"
:class="[
@@ -19,10 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only
}] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<template v-for="media in medias.previewable">
<XAudio
v-if="media.type.startsWith('audio')"
:key="`audio:${media.id}`"
:class="$style.media"
:audio="media"
@mediaClick="onMediaClick(media)"
/>
<XVideo
v-if="media.type.startsWith('video')"
:key="`video:${media.id}`"
:ref="(comp) => { mediaComponents.set(media.id, comp as InstanceType<typeof XVideo> | null); }"
:class="$style.media"
:video="media"
@mediaClick="onMediaClick(media)"
@@ -30,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XImage
v-else-if="media.type.startsWith('image')"
:key="`image:${media.id}`"
:ref="(comp) => { mediaComponents.set(media.id, comp as InstanceType<typeof XImage> | null); }"
:marker="`${markerId}:${media.id}`"
:disableImageLink="true"
:class="$style.media"
@@ -46,22 +55,42 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
import type { Content } from '@/components/MkLightbox.item.vue';
import type { MediaComponentExposes } from '@/types/media-component.js';
import XBanner from '@/components/MkMediaBanner.vue';
import XAudio from '@/components/MkMediaAudio.vue';
import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { isPreviewable, getType } from '@/utility/lightbox.js';
import { genId } from '@/utility/id.js';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
user?: Misskey.entities.User | null; // DriveFileusernull使
raw?: boolean;
}>();
const gallery = useTemplateRef('gallery');
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
const medias = computed(() => {
const previewable: Misskey.entities.DriveFile[] = [];
const nonPreviewable: Misskey.entities.DriveFile[] = [];
for (const file of props.mediaList) {
if (isPreviewable(file.type)) {
previewable.push(file);
} else {
nonPreviewable.push(file);
}
}
return {
previewable,
nonPreviewable,
};
});
const mediaComponents = new Map<string, MediaComponentExposes | null>();
const count = computed(() => medias.value.previewable.length);
const markerId = genId();
async function calcAspectRatio() {
@@ -102,14 +131,9 @@ onMounted(() => {
});
onUnmounted(() => {
mediaComponents.clear();
});
const previewable = (file: Misskey.entities.DriveFile): boolean => {
if (file.type === 'image/svg+xml') return true; // svgwebpublic/thumbnailpngtrue
// FILE_TYPE_BROWSERSAFE
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
function onMediaClick(file: Misskey.entities.DriveFile) {
if (prefer.s.imageNewTab) {
window.open(file.url, '_blank');
@@ -120,7 +144,7 @@ function onMediaClick(file: Misskey.entities.DriveFile) {
async function openGallery(id?: string) {
if (id == null) {
const firstImage = props.mediaList.find(media => previewable(media));
const firstImage = medias.value.previewable[0];
if (firstImage == null) return;
id = firstImage.id;
}
@@ -132,9 +156,9 @@ async function openGallery(id?: string) {
return markRaw(found);
};
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
const contents = medias.value.previewable.map<Content>(media => ({
id: media.id,
type: media.type.startsWith('video') ? 'video' : 'image',
type: getType(media.type),
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width,
@@ -144,9 +168,15 @@ async function openGallery(id?: string) {
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
}));
const initiallyRevealedContentIds = contents
.filter(content => mediaComponents.get(content.id)?.isRevealed() === true)
.map(content => content.id);
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(conten => conten.id === id),
contents: contents,
initiallyRevealedContentIds,
user: props.user,
}, {
closed: () => dispose(),
});
@@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import type { MediaComponentExposes } from '@/types/media-component.js';
import bytes from '@/filters/bytes.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
@@ -83,6 +84,10 @@ function showMenu(ev: PointerEvent) {
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
}
defineExpose<MediaComponentExposes>({
isRevealed: () => !hide.value,
});
</script>
<style lang="scss" module>
+1 -1
View File
@@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :user="appearNote.user"/>
</div>
<MkPoll
v-if="appearNote.poll"
@@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
@@ -111,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :user="appearNote.user"/>
</div>
<MkPoll
v-if="appearNote.poll"
@@ -251,7 +251,6 @@ import { Paginator } from '@/utility/paginator.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import number from '@/filters/number.js';
import { DI } from '@/di.js';
import { $i } from '@/i.js';
import type { Keymap } from '@/utility/hotkey.js';
//
@@ -333,14 +332,6 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
// MkNoteDetailed
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const badgeRoles = computed(() => {
const roles = appearNote.user.badgeRoles;
if (roles == null || $i == null || $i.id !== appearNote.userId) return roles;
//
const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []);
return roles.filter(role => !hiddenRoleIds.has(role.id));
});
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
limit: 10,
@@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="badgeRoles" :class="$style.badgeRoles">
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
<div :class="$style.info">
<div v-if="mock">
@@ -35,26 +35,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { inject } from 'vue';
import * as Misskey from 'misskey-js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import { DI } from '@/di.js';
const props = defineProps<{
defineProps<{
note: Misskey.entities.Note;
}>();
const badgeRoles = computed(() => {
const roles = props.note.user.badgeRoles;
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
//
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
return roles.filter(role => !hiddenRoleIds.has(role.id));
});
const mock = inject(DI.mock, false);
</script>
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<div class="_selectable">
<XValue :value="value" :collapsed="false"/>
</div>
</template>
@@ -52,6 +52,8 @@ import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
import type { Content } from '@/components/MkLightbox.item.vue';
import { isPreviewable, getType } from '@/utility/lightbox.js';
const props = defineProps<{
modelValue: Misskey.entities.DriveFile[];
@@ -152,9 +154,6 @@ async function describe(file: Misskey.entities.DriveFile) {
function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | KeyboardEvent): void {
if (menuShowing) return;
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const menuItems: MenuItem[] = [];
menuItems.push({
@@ -171,14 +170,14 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
action: () => { describe(file); },
});
if (isImage || isVideo) {
if (isPreviewable(file.type)) {
menuItems.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const constents = props.modelValue.filter(item => item.type.startsWith('image') || item.type.startsWith('video')).map(item => ({
const constents = props.modelValue.filter(item => isPreviewable(item.type)).map<Content>(item => ({
id: item.id,
type: item.type.startsWith('video') ? 'video' as const : 'image' as const,
type: getType(item.type),
url: item.url,
thumbnailUrl: item.thumbnailUrl,
width: item.properties.width,
@@ -190,6 +189,7 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: constents.findIndex(content => content.id === file.id),
contents: constents,
initiallyRevealedContentIds: [file.id],
}, {
closed: () => dispose(),
});
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<details v-if="note.files && note.files.length > 0">
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<MkMediaList :mediaList="note.files"/>
<MkMediaList :mediaList="note.files" :user="note.user"/>
</details>
<details v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary>
@@ -55,6 +55,7 @@ import type { UploaderItem } from '@/composables/use-uploader.js';
import { getUploadName } from '@/composables/use-uploader.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import { isPreviewable, getType } from '@/utility/lightbox.js';
import bytes from '@/filters/bytes.js';
import * as os from '@/os.js';
import type { Content } from '@/components/MkLightbox.item.vue';
@@ -101,12 +102,12 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
}
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
if (isPreviewable(item.file.type)) {
const contents = props.items
.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video'))
.filter(item => isPreviewable(item.file.type))
.map<Content>(item => ({
id: item.id,
type: (item.file.type.startsWith('video') ? 'video' : 'image'),
type: getType(item.file.type),
url: item.objectUrl,
thumbnailUrl: item.thumbnail,
filename: getUploadName(item),
@@ -1,318 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="[$style.seekbar]">
<MkMediaRange
v-model="rangePercent"
:buffer="bufferedDataRatio"
/>
</div>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<i v-if="isPlaying" class="ti ti-player-pause"></i>
<i v-else class="ti ti-player-play"></i>
</button>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
</div>
<div :class="[$style.controlsChild, $style.controlsCenter]">
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<MkMediaRange
v-model="volume"
:class="$style.volumeSeekbar"
/>
<button class="_button" :class="$style.controlButton" @click="showMenu">
<i class="ti ti-settings"></i>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { DI } from '@/di.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
const videoEl = inject(DI.mkLightboxItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
// Menu
const menuShowing = ref(false);
function showMenu(ev: PointerEvent) {
const menu: MenuItem[] = [
// TODO:
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: [{
label: '0.25x',
value: 0.25,
}, {
label: '0.5x',
value: 0.5,
}, {
label: '0.75x',
value: 0.75,
}, {
label: '1.0x',
value: 1,
}, {
label: '1.25x',
value: 1.25,
}, {
label: '1.5x',
value: 1.5,
}, {
label: '2.0x',
value: 2,
}],
},
...(window.document.pictureInPictureEnabled ? [{
text: i18n.ts._mediaControls.pip,
icon: 'ti ti-picture-in-picture',
action: togglePictureInPicture,
}] : []),
];
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
onClosing: () => {
menuShowing.value = false;
},
});
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
const isPlaying = ref(false);
const isActuallyPlaying = ref(false);
const elapsedTimeMs = ref(0);
const durationMs = ref(0);
const rangePercent = computed({
get: () => {
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
if (videoEl.value == null) return;
videoEl.value.currentTime = to * durationMs.value / 1000;
},
});
const volume = ref(.25);
const speed = ref(1);
const loop = ref(false); // TODO:
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (videoEl.value == null || videoEl.value.duration === 0) return 0;
return bufferedEnd.value / videoEl.value.duration;
});
function togglePlayPause() {
if (!isReady.value) return;
if (isPlaying.value) {
videoEl.value?.pause();
isPlaying.value = false;
} else {
videoEl.value?.play();
isPlaying.value = true;
oncePlayed.value = true;
}
}
function togglePictureInPicture() {
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture();
} else {
videoEl.value?.requestPictureInPicture();
}
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
} else {
volume.value = 0;
}
}
let abortController: AbortController | null = null;
let mediaTickFrameId: number | null = null;
function init() {
if (videoEl.value == null) return;
isReady.value = true;
abortController = new AbortController();
function updateMediaTick() {
if (videoEl.value == null) return;
try {
bufferedEnd.value = videoEl.value.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
if (videoEl.value.loop !== loop.value) {
loop.value = videoEl.value.loop;
}
if (videoEl.value.paused !== !isPlaying.value) {
isPlaying.value = !videoEl.value.paused;
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
videoEl.value.addEventListener('waiting', () => {
isActuallyPlaying.value = false;
}, { signal: abortController.signal });
videoEl.value.addEventListener('playing', () => {
isActuallyPlaying.value = true;
}, { signal: abortController.signal });
videoEl.value.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
}, { signal: abortController.signal });
videoEl.value.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
}, { signal: abortController.signal });
durationMs.value = videoEl.value.duration * 1000;
videoEl.value.addEventListener('durationchange', () => {
durationMs.value = videoEl.value!.duration * 1000;
}, { signal: abortController.signal });
videoEl.value.volume = volume.value;
hasAudio(videoEl.value).then(had => {
if (!had) {
videoEl.value!.loop = videoEl.value!.muted = true;
videoEl.value!.play();
}
});
}
watch(volume, (to) => {
if (videoEl.value == null) return;
videoEl.value.volume = to;
});
watch(speed, (to) => {
if (videoEl.value == null) return;
videoEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (videoEl.value == null) return;
videoEl.value.loop = to;
});
watch(videoEl, () => {
if (abortController != null) {
abortController.abort();
}
if (mediaTickFrameId != null) {
window.cancelAnimationFrame(mediaTickFrameId);
}
init();
}, { immediate: true });
onBeforeUnmount(() => {
if (mediaTickFrameId != null) {
window.cancelAnimationFrame(mediaTickFrameId);
}
});
defineExpose({
isPlaying,
isActuallyPlaying,
});
</script>
<style lang="scss" module>
.root {
display: grid;
grid-template-areas:
"seekbar seekbar seekbar"
"left center right";
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 4px 8px;
width: 100%;
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
}
.controlsLeft {
grid-area: left;
}
.controlsRight {
grid-area: right;
}
.controlsCenter {
grid-area: center;
justify-content: center;
}
.controlButton {
padding: 6px;
border-radius: 4px;
&:hover {
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
}
&:focus-visible {
outline: none;
}
}
.controlsTime {
font-size: 85%;
}
.seekbar {
grid-area: seekbar;
}
</style>
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/>
<MkMediaList v-if="image" :mediaList="[image]" :user="page.user" :class="$style.mediaList"/>
</div>
</template>
@@ -37,6 +37,7 @@ import { notePage } from '@/filters/note.js';
import type { DI as DIType } from '@/di.js';
import type { ExtractInjectedType } from '@/types/misc.js';
import type { MenuItem } from '@/types/menu.js';
import type { WordMuteResult } from '@/utility/check-word-mute.js';
export interface UseNoteProps {
note: Misskey.entities.Note;
@@ -66,7 +67,7 @@ export function checkNoteWordMute(
noteToCheck: Misskey.entities.Note,
user: typeof $i,
mutedWords: Array<string | string[]> | null,
): Array<string | string[]> | false {
): WordMuteResult {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, user, mutedWords);
if (Array.isArray(result)) return result;
@@ -114,6 +115,11 @@ export function useNote(
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
// nullになった場合(非表示)はこれ以上やることがないのでループを抜ける
if (result == null) {
break;
}
} catch (err) {
console.error(err);
}
@@ -121,7 +127,7 @@ export function useNote(
if (result == null) {
hideByPlugin = true;
} else {
rawNote = result as Misskey.entities.Note;
rawNote = result;
}
}
@@ -18,6 +18,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import type { Content } from '@/components/MkLightbox.item.vue';
import * as os from '@/os.js';
import { isPreviewable, getType } from '@/utility/lightbox.js';
import { ensureSignin } from '@/i.js';
export type UploaderFeatures = {
@@ -236,16 +237,16 @@ export function useUploader(options: {
},
});
if (item.file.type.startsWith('image/') || item.file.type.startsWith('video/')) {
if (isPreviewable(item.file.type)) {
menu.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const contents = items.value
.filter(item => item.file.type.startsWith('image/') || item.file.type.startsWith('video/'))
.filter(item => isPreviewable(item.file.type))
.map<Content>(item => ({
id: item.id,
type: item.file.type.startsWith('video/') ? 'video' : 'image',
type: getType(item.file.type),
url: item.objectUrl,
thumbnail: item.thumbnail,
filename: getUploadName(item),
+1 -1
View File
@@ -19,5 +19,5 @@ export const DI = {
inModal: Symbol() as InjectionKey<boolean>,
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
mkLightboxItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
mkLightboxItemMediaEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | HTMLAudioElement | null>>,
};
@@ -37,7 +37,7 @@ const props = defineProps<{
id?: string;
}>();
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isPublicDisplayRequired' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
condFormula: any;
policies: any;
};
@@ -62,7 +62,6 @@ if (props.id) {
target: 'manual',
condFormula: { id: genId(), type: 'isRemote' },
isPublic: false,
isPublicDisplayRequired: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
@@ -62,12 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
</MkSwitch>
<MkSwitch v-model="role.isPublicDisplayRequired" :disabled="!role.isPublic" :readonly="readonly">
<template #label>{{ i18n.ts._role.isPublicDisplayRequired }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsPublicDisplayRequired }}</template>
</MkSwitch>
<MkSwitch v-model="role.asBadge" :disabled="!role.isPublic" :readonly="readonly">
<MkSwitch v-model="role.asBadge" :readonly="readonly">
<template #label>{{ i18n.ts._role.asBadge }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
</MkSwitch>
@@ -115,7 +110,7 @@ import { instance } from '@/instance.js';
import { deepClone } from '@/utility/clone.js';
import type { PolicyMeta } from './roles.policy-editor.vue';
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isPublicDisplayRequired' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
id?: Misskey.entities.Role['id'] | null;
condFormula: any;
policies: any;
@@ -205,7 +200,6 @@ const save = throttle(100, () => {
isAdministrator: role.value.isAdministrator,
isModerator: role.value.isModerator,
isPublic: role.value.isPublic,
isPublicDisplayRequired: role.value.isPublicDisplayRequired,
isExplorable: role.value.isExplorable,
asBadge: role.value.asBadge,
canEditMembersByModerator: role.value.canEditMembersByModerator,
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :user="message.fromUser"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<div :class="$style.footer">
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-if="fetching"/>
<div v-else-if="file" class="_gaps">
<div :class="$style.filePreviewRoot">
<MkMediaList :mediaList="[file]"></MkMediaList>
<MkMediaList :mediaList="[file]" :user="file.user"></MkMediaList>
</div>
<div :class="$style.fileQuickActionsRoot">
<button class="_button" :class="$style.fileNameEditBtn" @click="rename()">
+13 -8
View File
@@ -49,6 +49,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['roles']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-badges"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template>
<div class="_gaps_s">
<MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['account', 'move', 'migration']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-plane"></i></SearchIcon></template>
@@ -115,15 +126,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<hr>
<div class="_gaps_s">
<!-- ページ移動したのでリンクを残してあるそのうち消しても良いかも -->
<FormLink to="/settings/roles">
<template #icon><i class="ti ti-badges"></i></template>
{{ i18n.ts.rolesAssignedToMe }}
</FormLink>
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
</div>
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
<hr>
@@ -162,6 +166,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import FormSection from '@/components/form/section.vue';
import { prefer } from '@/preferences.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import { signout } from '@/signout.js';
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
import { suggestReload } from '@/utility/reload-suggest.js';
@@ -72,11 +72,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</SearchMarker>
<FormLink to="/settings/roles">
<template #icon><i class="ti ti-badges"></i></template>
<SearchLabel>{{ i18n.ts._roleDisplay.title }}</SearchLabel>
</FormLink>
<SearchMarker :keywords="['chat']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template>
@@ -228,7 +223,6 @@ import { instance } from '@/instance.js';
import { ensureSignin } from '@/i.js';
import { definePage } from '@/page.js';
import FormSlot from '@/components/form/slot.vue';
import FormLink from '@/components/form/link.vue';
import { formatDateTimeString } from '@/utility/format-time-string.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import MkInput from '@/components/MkInput.vue';
@@ -1,94 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/roles" :label="i18n.ts.roles" :keywords="['role', 'badge']" icon="ti ti-badges">
<div class="_gaps_m">
<MkFeatureBanner icon="/fluent-emoji/1f3f7.png" color="#ffbf00">
<SearchText>{{ i18n.ts._roleDisplay.description }}</SearchText>
</MkFeatureBanner>
<div class="_gaps_s">
<MkResult v-if="roleDisplayRoles.length === 0" type="empty"/>
<div v-for="role in roleDisplayRoles" :key="role.id" class="_panel _gaps" :class="$style.roleItem">
<MkRolePreview :role="role" :forModeration="false"/>
<MkSwitch
:modelValue="isRoleDisplayShown(role)"
:disabled="role.isPublicDisplayRequired"
@update:modelValue="value => updateRoleDisplay(role, value)"
>
<template #label>{{ i18n.ts._roleDisplay.displayToggle }}</template>
<template v-if="role.isPublicDisplayRequired" #caption>{{ i18n.ts._roleDisplay.alwaysShownByAdmin }}</template>
<template v-else-if="role.isExplorable" #caption>
<i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <I18n :src="i18n.ts._roleDisplay.roleExplorableAlert">
<template #link>
<MkA class="_link" :to="`/roles/${role.id}`">{{ i18n.ts.explore }}</MkA>
</template>
</I18n>
</template>
</MkSwitch>
</div>
</div>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type * as Misskey from 'misskey-js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
import { definePage } from '@/page.js';
import { updateCurrentAccount } from '@/accounts.js';
const $i = ensureSignin();
const hiddenRoleIds = ref([...getHiddenRoleIds($i)]);
const roleDisplayRoles = computed(() => $i.roles);
function getHiddenRoleIds(user: { hiddenRoleIds?: string[] }): string[] {
return user.hiddenRoleIds ?? [];
}
function isRoleDisplayShown(role: Misskey.entities.RoleLite): boolean {
return role.isPublicDisplayRequired === true || !hiddenRoleIds.value.includes(role.id);
}
async function updateRoleDisplay(role: Misskey.entities.RoleLite, visible: boolean) {
if (role.isPublicDisplayRequired === true) return;
const nextHiddenRoleIds = new Set(hiddenRoleIds.value);
if (visible) {
nextHiddenRoleIds.delete(role.id);
} else {
nextHiddenRoleIds.add(role.id);
}
const nextIds = roleDisplayRoles.value
.filter(role => role.isPublicDisplayRequired !== true && nextHiddenRoleIds.has(role.id))
.map(role => role.id);
const updated = await misskeyApi('i/update', {
hiddenRoleIds: nextIds,
});
updateCurrentAccount(updated);
hiddenRoleIds.value = [...getHiddenRoleIds(updated)];
}
definePage(() => ({
title: i18n.ts.roles,
icon: 'ti ti-badges',
}));
</script>
<style lang="scss" module>
.roleItem {
padding: 16px;
}
</style>
+2 -10
View File
@@ -53,8 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
</MkFukidashi>
</div>
<div v-if="visibleProfileRoles.length > 0" class="roles">
<span v-for="role in visibleProfileRoles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }">
<div v-if="user.roles.length > 0" class="roles">
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }">
<MkA v-adaptive-bg :to="`/roles/${role.id}`">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
{{ role.name }}
@@ -222,14 +222,6 @@ const emit = defineEmits<{
const router = useRouter();
const user = ref(props.user);
const visibleProfileRoles = computed(() => {
const roles = user.value.roles;
if ($i == null || $i.id !== user.value.id) return roles;
//
const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []);
return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id));
});
const narrow = ref<null | boolean>(null);
const rootEl = useTemplateRef('rootEl');
const bannerEl = useTemplateRef('bannerEl');
@@ -88,10 +88,6 @@ export const ROUTE_DEF = [{
path: '/privacy',
name: 'privacy',
component: page(() => import('@/pages/settings/privacy.vue')),
}, {
path: '/roles',
name: 'roles',
component: page(() => import('@/pages/settings/roles.vue')),
}, {
path: '/emoji-palette',
name: 'emoji-palette',
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type MediaComponentExposes = {
isRevealed: () => boolean;
};
@@ -4,35 +4,66 @@
*/
import * as Misskey from 'misskey-js';
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
export type WordMuteResult = Array<string | string[]> | false;
// ミュートワードの判定はタイムラインのノートごとに繰り返し実行されるため、
// フィルタ単位の前処理(正規表現のコンパイル・空キーワードの除去)をキャッシュする
// keywordsCacheはWeakMapによりフィルタ配列がGCされるとキャッシュも破棄される
const regexCache = new Map<string, RegExp | null>();
const keywordsCache = new WeakMap<string[], string[]>();
function getCompiledRegex(filter: string): RegExp | null {
let regex = regexCache.get(filter);
if (regex === undefined) {
const parts = filter.match(/^\/(.+)\/(.*)$/);
if (parts == null) {
// This should never happen due to input sanitisation.
regex = null;
} else {
try {
regex = new RegExp(parts[1], parts[2]);
} catch (_) {
// This should never happen due to input sanitisation.
regex = null;
}
}
regexCache.set(filter, regex);
}
return regex;
}
function getCleanedKeywords(filter: string[]): string[] {
let keywords = keywordsCache.get(filter);
if (keywords === undefined) {
keywords = filter.filter(keyword => keyword !== '');
keywordsCache.set(filter, keywords);
}
return keywords;
}
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): WordMuteResult {
// 自分自身
if (me && (note.userId === me.id)) return false;
if (mutedWords.length > 0) {
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
if (mutedWords.length === 0) return false;
if (text === '') return false;
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
if (text !== '') {
const matched = mutedWords.filter(filter => {
if (Array.isArray(filter)) {
// Clean up
const filteredFilter = filter.filter(keyword => keyword !== '');
if (filteredFilter.length === 0) return false;
const keywords = getCleanedKeywords(filter);
if (keywords.length === 0) return false;
return filteredFilter.every(keyword => text.includes(keyword));
return keywords.every(keyword => text.includes(keyword));
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
const regex = getCompiledRegex(filter);
if (regex == null) return false;
// This should never happen due to input sanitisation.
if (!regexp) return false;
try {
return new RegExp(regexp[1], regexp[2]).test(text);
} catch (_) {
// This should never happen due to input sanitisation.
return false;
}
// gフラグ等が付いているとtestでlastIndexが進み、共有インスタンスでは結果が変わってしまうためリセットする
regex.lastIndex = 0;
return regex.test(text);
}
});
+10 -4
View File
@@ -303,12 +303,18 @@ export function getNoteMenu(props: {
async function translate(): Promise<void> {
if (props.translation.value != null) return;
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
let text = appearNote.text ?? '';
if (appearNote.cw != null) {
text = `${appearNote.cw}\n-----\n${text}`;
}
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && text.trim() !== '') {
props.translating.value = true;
try {
// @ts-expect-error 実験的なAPIなので型定義がない
const detector = await LanguageDetector.create();
const langResult = await detector.detect(appearNote.text);
const langResult = await detector.detect(text);
let localStorageLang = miLocalStorage.getItem('lang');
if (localStorageLang != null) {
localStorageLang = localStorageLang.split('-')[0];
@@ -318,7 +324,7 @@ export function getNoteMenu(props: {
if (langResult[0]?.detectedLanguage === localStorageLang || langResult[0]?.detectedLanguage === navigator.language) {
props.translation.value = {
sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
text: appearNote.text,
text: text,
};
return;
}
@@ -328,7 +334,7 @@ export function getNoteMenu(props: {
sourceLanguage: langResult[0]?.detectedLanguage,
targetLanguage: localStorageLang ?? navigator.language,
});
const translated = await translator.translate(appearNote.text);
const translated = await translator.translate(text);
props.translation.value = {
sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
text: translated,
+19
View File
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
export function isPreviewable(mime: string): boolean {
if (mime === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
const strippedMime = mime.split(';')[0].trim();
return (strippedMime.startsWith('image') || strippedMime.startsWith('video') || strippedMime.startsWith('audio')) && FILE_TYPE_BROWSERSAFE.includes(strippedMime);
}
export function getType(mime: string): 'image' | 'video' | 'audio' {
if (mime.startsWith('image')) return 'image';
if (mime.startsWith('video')) return 'video';
if (mime.startsWith('audio')) return 'audio';
throw new Error(`Unsupported file type: ${mime}`);
}
+4 -30
View File
@@ -4172,6 +4172,10 @@ export interface Locale extends ILocale {
*
*/
"cannotLoad": string;
/**
*
*/
"cannotPreview": string;
/**
*
*/
@@ -8030,14 +8034,6 @@ export interface Locale extends ILocale {
*
*/
"descriptionOfIsPublic": string;
/**
*
*/
"isPublicDisplayRequired": string;
/**
* /
*/
"descriptionOfIsPublicDisplayRequired": string;
/**
*
*/
@@ -12191,28 +12187,6 @@ export interface Locale extends ILocale {
"notUnique": string;
};
};
"_roleDisplay": {
/**
* /
*/
"title": string;
/**
*
*/
"description": string;
/**
* {link}
*/
"roleExplorableAlert": ParameterizedString<"link">;
/**
* /
*/
"displayToggle": string;
/**
*
*/
"alwaysShownByAdmin": string;
};
"_roleSelectDialog": {
/**
*
-15
View File
@@ -4061,8 +4061,6 @@ export type components = {
/** @enum {string} */
onlineStatus: 'unknown' | 'online' | 'active' | 'offline';
badgeRoles?: {
/** Format: id */
id: string;
name: string;
iconUrl: string | null;
displayOrder: number;
@@ -4153,7 +4151,6 @@ export type components = {
preventAiLearning: boolean;
isExplorable: boolean;
isDeleted: boolean;
hiddenRoleIds: string[];
/** @enum {string} */
twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
hideOnlineStatus: boolean;
@@ -5325,12 +5322,6 @@ export type components = {
isModerator: boolean;
/** @example false */
isAdministrator: boolean;
/** @example false */
asBadge: boolean;
/** @example false */
isPublicDisplayRequired: boolean;
/** @example false */
isExplorable: boolean;
/** @example 0 */
displayOrder: number;
};
@@ -5345,8 +5336,6 @@ export type components = {
/** @example false */
isPublic: boolean;
/** @example false */
isPublicDisplayRequired: boolean;
/** @example false */
isExplorable: boolean;
/** @example false */
asBadge: boolean;
@@ -11052,8 +11041,6 @@ export interface operations {
target: 'manual' | 'conditional';
condFormula: Record<string, never>;
isPublic: boolean;
/** @default false */
isPublicDisplayRequired?: boolean;
isModerator: boolean;
isAdministrator: boolean;
/** @default false */
@@ -11389,7 +11376,6 @@ export interface operations {
target?: 'manual' | 'conditional';
condFormula?: Record<string, never>;
isPublic?: boolean;
isPublicDisplayRequired?: boolean;
isModerator?: boolean;
isAdministrator?: boolean;
isExplorable?: boolean;
@@ -27912,7 +27898,6 @@ export interface operations {
followersVisibility?: 'public' | 'followers' | 'private';
/** @enum {string} */
chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
hiddenRoleIds?: string[];
/** Format: misskey:id */
pinnedPageId?: string | null;
mutedWords?: (string[] | string)[];