Merge branch 'develop' into fix-13766

This commit is contained in:
かっこかり 2024-09-06 15:50:34 +09:00 committed by GitHub
commit b2b13ac13e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 1360 additions and 660 deletions

View file

@ -40,8 +40,6 @@ jobs:
needs: [pnpm_install] needs: [pnpm_install]
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
eslint-cache-version: v1
strategy: strategy:
matrix: matrix:
workspace: workspace:
@ -49,6 +47,9 @@ jobs:
- frontend - frontend
- sw - sw
- misskey-js - misskey-js
env:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4.1.1
with: with:
@ -64,11 +65,10 @@ jobs:
- name: Restore eslint cache - name: Restore eslint cache
uses: actions/cache@v4.0.2 uses: actions/cache@v4.0.2
with: with:
path: node_modules/.cache/eslint path: ${{ env.eslint-cache-path }}
key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: | restore-keys: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}- - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location ${{ env.eslint-cache-path }} --cache-strategy content
- run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content
typecheck: typecheck:
needs: [pnpm_install] needs: [pnpm_install]
@ -78,6 +78,7 @@ jobs:
matrix: matrix:
workspace: workspace:
- backend - backend
- sw
- misskey-js - misskey-js
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4.1.1
@ -92,7 +93,7 @@ jobs:
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- run: pnpm --filter misskey-js run build - run: pnpm --filter misskey-js run build
if: ${{ matrix.workspace == 'backend' }} if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }}
- run: pnpm --filter misskey-reversi run build - run: pnpm --filter misskey-reversi run build
if: ${{ matrix.workspace == 'backend' }} if: ${{ matrix.workspace == 'backend' }}
- run: pnpm --filter ${{ matrix.workspace }} run typecheck - run: pnpm --filter ${{ matrix.workspace }} run typecheck

1
.gitignore vendored
View file

@ -44,6 +44,7 @@ compose.yml
/build /build
built built
built-test built-test
js-built
/data /data
/.cache-loader /.cache-loader
/db /db

View file

@ -1,17 +1,38 @@
## Unreleased
### General
-
### Client
- サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
- Enhance: アイコンデコレーション管理画面にプレビューを追加
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
### Server
- ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正
## 2024.8.0 ## 2024.8.0
### General ### General
- Fix: リモートユーザのフォロー・フォロワーの一覧が非公開設定の場合も表示できてしまう問題を修正
- Enhance: モデレーターはすべてのユーザーのフォロー・フォロワーの一覧を見られるように - Enhance: モデレーターはすべてのユーザーのフォロー・フォロワーの一覧を見られるように
- Enhance: アカウントの削除のモデレーションログを残すように
- Enhance: 不適切なページ、ギャラリー、Playを管理者権限で削除できるように
- Fix: リモートユーザのフォロー・フォロワーの一覧が非公開設定の場合も表示できてしまう問題を修正
### Client ### Client
- Enhance: 「自分のPlay」ページにおいてPlayが非公開かどうかが一目でわかるように - Enhance: 「自分のPlay」ページにおいてPlayが非公開かどうかが一目でわかるように
- Enhance: 不適切なページ、ギャラリー、Playを通報できるように
- Fix: Play編集時に公開範囲が「パブリック」にリセットされる問題を修正 - Fix: Play編集時に公開範囲が「パブリック」にリセットされる問題を修正
- Fix: ページ遷移に失敗することがある問題を修正 - Fix: ページ遷移に失敗することがある問題を修正
- Fix: iOSでユーザー名などがリンクとして誤検知される現象を抑制 - Fix: iOSでユーザー名などがリンクとして誤検知される現象を抑制
- Fix: mCaptchaを使用していてもbotプロテクションに関する警告が消えないのを修正 - Fix: mCaptchaを使用していてもbotプロテクションに関する警告が消えないのを修正
- Fix: ユーザーのモデレーションページにおいてユーザー名にドットが入っているとシステムアカウントとして表示されてしまう問題を修正
- Fix: 特定の条件下でノートの削除ボタンが出ないのを修正
### Server ### Server
- Enhance: 照会時にURLがhtmlかつheadタグ内に`rel="alternate"`, `type="application/activity+json"`の`link`タグがある場合に追ってリンク先を照会できるように
- Enhance: 凍結されたアカウントのフォローリクエストを表示しないように
- Fix: WSの`readAllNotifications` メッセージが `body` を持たない場合に動作しない問題 #14374 - Fix: WSの`readAllNotifications` メッセージが `body` を持たない場合に動作しない問題 #14374
- 通知ページや通知カラム(デッキ)を開いている状態において、新たに発生した通知が既読されない問題が修正されます。 - 通知ページや通知カラム(デッキ)を開いている状態において、新たに発生した通知が既読されない問題が修正されます。
- これにより、プッシュ通知が有効な同条件下の環境において、プッシュ通知が常に発生してしまう問題も修正されます。 - これにより、プッシュ通知が有効な同条件下の環境において、プッシュ通知が常に発生してしまう問題も修正されます。
@ -22,6 +43,12 @@
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/679) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/679)
- Fix: ActivityPubのエンティティタイプ判定で不明なタイプを受け取った場合でも処理を継続するように - Fix: ActivityPubのエンティティタイプ判定で不明なタイプを受け取った場合でも処理を継続するように
- キュー処理のつまりが改善される可能性があります - キュー処理のつまりが改善される可能性があります
- Fix: リバーシの対局設定の変更が反映されないのを修正
- Fix: 無制限にストリーミングのチャンネルに接続できる問題を修正
- Fix: ベースロールのポリシーを変更した際にモデログに記録されないのを修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/700)
- Fix: Prevent memory leak from memory caches (#14310)
- Fix: More reliable memory cache eviction (#14311)
## 2024.7.0 ## 2024.7.0

View file

@ -182,7 +182,7 @@ addAccount: "Add account"
reloadAccountsList: "Reload account list" reloadAccountsList: "Reload account list"
loginFailed: "Failed to sign in" loginFailed: "Failed to sign in"
showOnRemote: "View on remote instance" showOnRemote: "View on remote instance"
continueOnRemote: "リモートで続行" continueOnRemote: "Continue on a remote server"
chooseServerOnMisskeyHub: "Choose a server from the Misskey Hub" chooseServerOnMisskeyHub: "Choose a server from the Misskey Hub"
specifyServerHost: "Specify a server host directly" specifyServerHost: "Specify a server host directly"
inputHostName: "Enter the domain" inputHostName: "Enter the domain"
@ -487,7 +487,7 @@ noMessagesYet: "No messages yet"
newMessageExists: "There are new messages" newMessageExists: "There are new messages"
onlyOneFileCanBeAttached: "You can only attach one file to a message" onlyOneFileCanBeAttached: "You can only attach one file to a message"
signinRequired: "Please register or sign in before continuing" signinRequired: "Please register or sign in before continuing"
signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server."
invitations: "Invites" invitations: "Invites"
invitationCode: "Invitation code" invitationCode: "Invitation code"
checking: "Checking..." checking: "Checking..."
@ -2316,6 +2316,7 @@ _pages:
eyeCatchingImageSet: "Set thumbnail" eyeCatchingImageSet: "Set thumbnail"
eyeCatchingImageRemove: "Delete thumbnail" eyeCatchingImageRemove: "Delete thumbnail"
chooseBlock: "Add a block" chooseBlock: "Add a block"
enterSectionTitle: "Enter a section title"
selectType: "Select a type" selectType: "Select a type"
contentBlocks: "Content" contentBlocks: "Content"
inputBlocks: "Input" inputBlocks: "Input"
@ -2499,6 +2500,10 @@ _moderationLogTypes:
createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" createAbuseReportNotificationRecipient: "Create a recipient for abuse reports"
updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" updateAbuseReportNotificationRecipient: "Update recipients for abuse reports"
deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports"
deleteAccount: "Delete the account"
deletePage: "Delete the page"
deleteFlash: "Delete Play"
deleteGalleryPost: "Delete the gallery post"
_fileViewer: _fileViewer:
title: "File details" title: "File details"
type: "File type" type: "File type"

View file

@ -60,6 +60,7 @@ copyFileId: "Copiar ID del archivo"
copyFolderId: "Copiar ID de carpeta" copyFolderId: "Copiar ID de carpeta"
copyProfileUrl: "Copiar la URL del perfil" copyProfileUrl: "Copiar la URL del perfil"
searchUser: "Buscar un usuario" searchUser: "Buscar un usuario"
searchThisUsersNotes: ""
reply: "Responder" reply: "Responder"
loadMore: "Ver más" loadMore: "Ver más"
showMore: "Ver más" showMore: "Ver más"

26
locales/index.d.ts vendored
View file

@ -2829,7 +2829,7 @@ export interface Locale extends ILocale {
*/ */
"reportAbuseOf": ParameterizedString<"name">; "reportAbuseOf": ParameterizedString<"name">;
/** /**
* URLも記入してください * URLも記入してください
*/ */
"fillAbuseReportDescription": string; "fillAbuseReportDescription": string;
/** /**
@ -5068,6 +5068,10 @@ export interface Locale extends ILocale {
* *
*/ */
"createdAntennas": string; "createdAntennas": string;
/**
*
*/
"clipNoteLimitExceeded": string;
"_delivery": { "_delivery": {
/** /**
* *
@ -8985,6 +8989,10 @@ export interface Locale extends ILocale {
* *
*/ */
"chooseBlock": string; "chooseBlock": string;
/**
*
*/
"enterSectionTitle": string;
/** /**
* *
*/ */
@ -9679,6 +9687,22 @@ export interface Locale extends ILocale {
* *
*/ */
"deleteAbuseReportNotificationRecipient": string; "deleteAbuseReportNotificationRecipient": string;
/**
*
*/
"deleteAccount": string;
/**
*
*/
"deletePage": string;
/**
* Playを削除
*/
"deleteFlash": string;
/**
* 稿
*/
"deleteGalleryPost": string;
}; };
"_fileViewer": { "_fileViewer": {
/** /**

View file

@ -703,7 +703,7 @@ abuseReports: "通報"
reportAbuse: "通報" reportAbuse: "通報"
reportAbuseRenote: "リノートを通報" reportAbuseRenote: "リノートを通報"
reportAbuseOf: "{name}を通報する" reportAbuseOf: "{name}を通報する"
fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートやページなどがある場合はそのURLも記入してください。"
abuseReported: "内容が送信されました。ご報告ありがとうございました。" abuseReported: "内容が送信されました。ご報告ありがとうございました。"
reporter: "通報者" reporter: "通報者"
reporteeOrigin: "通報先" reporteeOrigin: "通報先"
@ -1263,6 +1263,7 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示
sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
createdLists: "作成したリスト" createdLists: "作成したリスト"
createdAntennas: "作成したアンテナ" createdAntennas: "作成したアンテナ"
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
_delivery: _delivery:
status: "配信状態" status: "配信状態"
@ -2371,6 +2372,7 @@ _pages:
eyeCatchingImageSet: "アイキャッチ画像を設定" eyeCatchingImageSet: "アイキャッチ画像を設定"
eyeCatchingImageRemove: "アイキャッチ画像を削除" eyeCatchingImageRemove: "アイキャッチ画像を削除"
chooseBlock: "ブロックを追加" chooseBlock: "ブロックを追加"
enterSectionTitle: "セクションタイトルを入力"
selectType: "種類を選択" selectType: "種類を選択"
contentBlocks: "コンテンツ" contentBlocks: "コンテンツ"
inputBlocks: "入力" inputBlocks: "入力"
@ -2567,6 +2569,10 @@ _moderationLogTypes:
createAbuseReportNotificationRecipient: "通報の通知先を作成" createAbuseReportNotificationRecipient: "通報の通知先を作成"
updateAbuseReportNotificationRecipient: "通報の通知先を更新" updateAbuseReportNotificationRecipient: "通報の通知先を更新"
deleteAbuseReportNotificationRecipient: "通報の通知先を削除" deleteAbuseReportNotificationRecipient: "通報の通知先を削除"
deleteAccount: "アカウントを削除"
deletePage: "ページを削除"
deleteFlash: "Playを削除"
deleteGalleryPost: "ギャラリーの投稿を削除"
_fileViewer: _fileViewer:
title: "ファイルの詳細" title: "ファイルの詳細"

View file

@ -1675,7 +1675,7 @@ _role:
descriptionOfPermission: "<b>Moderador</b> permite que você execute operações básicas relacionadas à moderação.\n<b>Administradores</b> podem alterar todas as configurações do servidor." descriptionOfPermission: "<b>Moderador</b> permite que você execute operações básicas relacionadas à moderação.\n<b>Administradores</b> podem alterar todas as configurações do servidor."
assignTarget: "Atribuir" assignTarget: "Atribuir"
descriptionOfAssignTarget: "<b>Manual</b> para gerenciar manualmente quem está incluído neste cargo.\n<b>Condicional</b> define uma condição e os usuários que corresponderem a ela serão incluídos automaticamente." descriptionOfAssignTarget: "<b>Manual</b> para gerenciar manualmente quem está incluído neste cargo.\n<b>Condicional</b> define uma condição e os usuários que corresponderem a ela serão incluídos automaticamente."
manual: "Documentação" manual: "Manual"
manualRoles: "Cargos manuais" manualRoles: "Cargos manuais"
conditional: "Condicional" conditional: "Condicional"
conditionalRoles: "Cargos condicionais" conditionalRoles: "Cargos condicionais"

View file

@ -2316,6 +2316,7 @@ _pages:
eyeCatchingImageSet: "设置封面图片" eyeCatchingImageSet: "设置封面图片"
eyeCatchingImageRemove: "删除封面图片" eyeCatchingImageRemove: "删除封面图片"
chooseBlock: "添加块" chooseBlock: "添加块"
enterSectionTitle: "输入会话标题"
selectType: "选择类型" selectType: "选择类型"
contentBlocks: "内容" contentBlocks: "内容"
inputBlocks: "输入" inputBlocks: "输入"
@ -2499,6 +2500,10 @@ _moderationLogTypes:
createAbuseReportNotificationRecipient: "新建了举报通知" createAbuseReportNotificationRecipient: "新建了举报通知"
updateAbuseReportNotificationRecipient: "更新了举报通知" updateAbuseReportNotificationRecipient: "更新了举报通知"
deleteAbuseReportNotificationRecipient: "删除了举报通知" deleteAbuseReportNotificationRecipient: "删除了举报通知"
deleteAccount: "删除了账户"
deletePage: "删除了页面"
deleteFlash: "删除了 Play"
deleteGalleryPost: "删除了图库稿件"
_fileViewer: _fileViewer:
title: "文件信息" title: "文件信息"
type: "文件类型" type: "文件类型"

View file

@ -2316,6 +2316,7 @@ _pages:
eyeCatchingImageSet: "設定封面影像" eyeCatchingImageSet: "設定封面影像"
eyeCatchingImageRemove: "刪除封面影像" eyeCatchingImageRemove: "刪除封面影像"
chooseBlock: "新增方塊" chooseBlock: "新增方塊"
enterSectionTitle: "輸入區段的標題"
selectType: "選擇類型" selectType: "選擇類型"
contentBlocks: "內容" contentBlocks: "內容"
inputBlocks: "輸入" inputBlocks: "輸入"
@ -2499,6 +2500,10 @@ _moderationLogTypes:
createAbuseReportNotificationRecipient: "建立接收檢舉的通知對象" createAbuseReportNotificationRecipient: "建立接收檢舉的通知對象"
updateAbuseReportNotificationRecipient: "更新接收檢舉的通知對象" updateAbuseReportNotificationRecipient: "更新接收檢舉的通知對象"
deleteAbuseReportNotificationRecipient: "刪除接收檢舉的通知對象" deleteAbuseReportNotificationRecipient: "刪除接收檢舉的通知對象"
deleteAccount: "刪除帳戶"
deletePage: "刪除頁面"
deleteFlash: "刪除 Play"
deleteGalleryPost: "刪除相簿的貼文"
_fileViewer: _fileViewer:
title: "檔案詳細資訊" title: "檔案詳細資訊"
type: "檔案類型 " type: "檔案類型 "

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.8.0-alpha.0", "version": "2024.8.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -61,7 +61,7 @@
"glob": "11.0.0" "glob": "11.0.0"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "2.0.2", "@misskey-dev/eslint-plugin": "2.0.3",
"@types/node": "20.14.12", "@types/node": "20.14.12",
"@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/eslint-plugin": "7.17.0",
"@typescript-eslint/parser": "7.17.0", "@typescript-eslint/parser": "7.17.0",

View file

@ -133,7 +133,7 @@ export type Config = {
proxySmtp: string | undefined; proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined; proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined; allowedPrivateNetworks: string[] | undefined;
maxFileSize: number | undefined; maxFileSize: number;
clusterLimit: number | undefined; clusterLimit: number | undefined;
id: string; id: string;
outgoingAddress: string | undefined; outgoingAddress: string | undefined;
@ -250,7 +250,7 @@ export function loadConfig(): Config {
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts, proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks, allowedPrivateNetworks: config.allowedPrivateNetworks,
maxFileSize: config.maxFileSize, maxFileSize: config.maxFileSize ?? 262144000,
clusterLimit: config.clusterLimit, clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress, outgoingAddress: config.outgoingAddress,
outgoingAddressFamily: config.outgoingAddressFamily, outgoingAddressFamily: config.outgoingAddressFamily,

View file

@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }

View file

@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new MemoryKVCache<MiUser>(Infinity); this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity); this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity); this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity); this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', { this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown {
if (user == null) { if (user == null) {
this.userByIdCache.delete(body.id); this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id); this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.entries) {
if (v.value?.id === body.id) { if (v.value?.id === body.id) {
this.uriPersonCache.delete(k); this.uriPersonCache.delete(k);
} }
} }
} else { } else {
this.userByIdCache.set(user.id, user); this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.entries) {
if (v.value?.id === user.id) { if (v.value?.id === user.id) {
this.uriPersonCache.set(k, user); this.uriPersonCache.set(k, user);
} }

View file

@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
export class CustomEmojiService implements OnApplicationShutdown { export class CustomEmojiService implements OnApplicationShutdown {
private cache: MemoryKVCache<MiEmoji | null>; private emojisCache: MemoryKVCache<MiEmoji | null>;
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>; public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;
constructor( constructor(
@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h
this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', { this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@ -334,7 +334,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
host, host,
})) ?? null; })) ?? null;
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
@ -361,7 +361,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null);
const emojisQuery: any[] = []; const emojisQuery: any[] = [];
const hosts = new Set(notCachedEmojis.map(e => e.host)); const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) { for (const host of hosts) {
@ -376,7 +376,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
select: ['name', 'host', 'originalUrl', 'publicUrl'], select: ['name', 'host', 'originalUrl', 'publicUrl'],
}) : []; }) : [];
for (const emoji of _emojis) { for (const emoji of _emojis) {
this.cache.set(`${emoji.name} ${emoji.host}`, emoji); this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji);
} }
} }
@ -401,7 +401,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.emojisCache.dispose();
} }
@bindThis @bindThis

View file

@ -4,12 +4,15 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js'; import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable() @Injectable()
export class DeleteAccountService { export class DeleteAccountService {
@ -17,9 +20,14 @@ export class DeleteAccountService {
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userSuspendService: UserSuspendService, @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
private queueService: QueueService, private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
) { ) {
} }
@ -27,16 +35,52 @@ export class DeleteAccountService {
public async deleteAccount(user: { public async deleteAccount(user: {
id: string; id: string;
host: string | null; host: string | null;
}): Promise<void> { }, moderator?: MiUser): Promise<void> {
const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); const _user = await this.usersRepository.findOneByOrFail({ id: user.id });
if (_user.isRoot) throw new Error('cannot delete a root account'); if (_user.isRoot) throw new Error('cannot delete a root account');
// 物理削除する前にDelete activityを送信する if (moderator != null) {
await this.userSuspendService.doPostSuspend(user).catch(e => {}); this.moderationLogService.log(moderator, 'deleteAccount', {
userId: user.id,
userUsername: _user.username,
userHost: user.host,
});
}
this.queueService.createDeleteAccountJob(user, { // 物理削除する前にDelete activityを送信する
soft: false, if (this.userEntityService.isLocalUser(user)) {
}); // 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox, true);
}
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
} else {
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
this.queueService.createDeleteAccountJob(user, {
soft: true,
});
}
await this.usersRepository.update(user.id, { await this.usersRepository.update(user.id, {
isDeleted: true, isDeleted: true,

View file

@ -42,7 +42,7 @@ export class DownloadService {
const timeout = 30 * 1000; const timeout = 30 * 1000;
const operationTimeout = 60 * 1000; const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize ?? 262144000; const maxSize = this.config.maxFileSize;
const urlObj = new URL(url); const urlObj = new URL(url);
let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; let filename = urlObj.pathname.split('/').pop() ?? 'untitled';

View file

@ -35,7 +35,7 @@ export class RelayService {
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
) { ) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m
} }
@bindThis @bindThis

View file

@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { reversiUpdateKeys } from 'misskey-js';
import * as Reversi from 'misskey-reversi'; import * as Reversi from 'misskey-reversi';
import { IsNull, LessThan, MoreThan } from 'typeorm'; import { IsNull, LessThan, MoreThan } from 'typeorm';
import type { import type {
@ -399,7 +400,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) { public isValidReversiUpdateKey(key: unknown): key is typeof reversiUpdateKeys[number] {
if (typeof key !== 'string') return false;
return (reversiUpdateKeys as string[]).includes(key);
}
@bindThis
public isValidReversiUpdateValue<K extends typeof reversiUpdateKeys[number]>(key: K, value: unknown): value is MiReversiGame[K] {
switch (key) {
case 'map':
return Array.isArray(value) && value.every(row => typeof row === 'string');
case 'bw':
return typeof value === 'string' && ['random', '1', '2'].includes(value);
case 'isLlotheo':
return typeof value === 'boolean';
case 'canPutEverywhere':
return typeof value === 'boolean';
case 'loopedBoard':
return typeof value === 'boolean';
case 'timeLimitForEachTurn':
return typeof value === 'number' && value >= 0;
default:
return false;
}
}
@bindThis
public async updateSettings<K extends typeof reversiUpdateKeys[number]>(gameId: MiReversiGame['id'], user: MiUser, key: K, value: MiReversiGame[K]) {
const game = await this.get(gameId); const game = await this.get(gameId);
if (game == null) throw new Error('game not found'); if (game == null) throw new Error('game not found');
if (game.isStarted) return; if (game.isStarted) return;
@ -407,10 +434,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if ((game.user1Id === user.id) && game.user1Ready) return; if ((game.user1Id === user.id) && game.user1Ready) return;
if ((game.user2Id === user.id) && game.user2Ready) return; if ((game.user2Id === user.id) && game.user2Ready) return;
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return;
// TODO: より厳格なバリデーション
const updatedGame = { const updatedGame = {
...game, ...game,
[key]: value, [key]: value,

View file

@ -127,10 +127,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
//this.onMessage = this.onMessage.bind(this); this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }

View file

@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown {
) { ) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: Infinity, memoryCacheLifetime: 1000 * 60 * 60, // 1h
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm'; import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository } from '@/models/_.js'; import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
@ -13,24 +13,75 @@ import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable() @Injectable()
export class UserSuspendService { export class UserSuspendService {
constructor( constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private queueService: QueueService, private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
) { ) {
} }
@bindThis @bindThis
public async doPostSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> { public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
await this.usersRepository.update(user.id, {
isSuspended: true,
});
this.moderationLogService.log(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.postSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
})();
}
@bindThis
public async unsuspend(user: MiUser, moderator: MiUser): Promise<void> {
await this.usersRepository.update(user.id, {
isSuspended: false,
});
this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.postUnsuspend(user).catch(e => {});
})();
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
this.followRequestsRepository.delete({
followeeId: user.id,
});
this.followRequestsRepository.delete({
followerId: user.id,
});
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信 // 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
@ -58,7 +109,7 @@ export class UserSuspendService {
} }
@bindThis @bindThis
public async doPostUnsuspend(user: MiUser): Promise<void> { private async postUnsuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
@ -86,4 +137,26 @@ export class UserSuspendService {
} }
} }
} }
@bindThis
private async unFollowAll(follower: MiUser) {
const followings = await this.followingsRepository.find({
where: {
followerId: follower.id,
followeeId: Not(IsNull()),
},
});
const jobs: RelationshipJobData[] = [];
for (const following of followings) {
if (following.followeeId && following.followerId) {
jobs.push({
from: { id: following.followerId },
to: { id: following.followeeId },
silent: true,
});
}
}
this.queueService.createUnfollowJob(jobs);
}
} }

View file

@ -54,8 +54,8 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
} }
@bindThis @bindThis

View file

@ -6,6 +6,7 @@
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Window } from 'happy-dom';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
@ -180,7 +181,8 @@ export class ApRequestService {
* @param url URL to fetch * @param url URL to fetch
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> { public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id); const keypair = await this.userKeypairService.getUserKeypair(user.id);
const req = ApRequestCreator.createSignedGet({ const req = ApRequestCreator.createSignedGet({
@ -198,9 +200,29 @@ export class ApRequestService {
headers: req.request.headers, headers: req.request.headers,
}, { }, {
throwErrorWhenResponseNotOk: true, throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
}); });
//#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき
const contentType = res.headers.get('content-type');
if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
const html = await res.text();
const window = new Window();
const document = window.document;
document.documentElement.innerHTML = html;
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) {
const href = alternate.getAttribute('href');
if (href) {
return await this.signedGet(href, user, false);
}
}
}
//#endregion
validateContentTypeSetAsActivityPub(res);
return await res.json(); return await res.json();
} }
} }

View file

@ -65,21 +65,21 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
this.followingsRepository.createQueryBuilder('following') this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)') .select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL') .where('following.followeeHost IS NOT NULL')
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.getRawOne() .getRawOne()
.then(x => parseInt(x.count, 10)), .then(x => parseInt(x.count, 10)),
this.followingsRepository.createQueryBuilder('following') this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followerHost)') .select('COUNT(DISTINCT following.followerHost)')
.where('following.followerHost IS NOT NULL') .where('following.followerHost IS NOT NULL')
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.getRawOne() .getRawOne()
.then(x => parseInt(x.count, 10)), .then(x => parseInt(x.count, 10)),
this.followingsRepository.createQueryBuilder('following') this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)') .select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL') .where('following.followeeHost IS NOT NULL')
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
.setParameters(pubsubSubQuery.getParameters()) .setParameters(pubsubSubQuery.getParameters())
@ -88,7 +88,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
this.instancesRepository.createQueryBuilder('instance') this.instancesRepository.createQueryBuilder('instance')
.select('COUNT(instance.id)') .select('COUNT(instance.id)')
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`) .where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere('instance.suspensionState = \'none\'') .andWhere('instance.suspensionState = \'none\'')
.andWhere('instance.isNotResponding = false') .andWhere('instance.isNotResponding = false')
.getRawOne() .getRawOne()
@ -96,7 +96,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
this.instancesRepository.createQueryBuilder('instance') this.instancesRepository.createQueryBuilder('instance')
.select('COUNT(instance.id)') .select('COUNT(instance.id)')
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere('instance.suspensionState = \'none\'') .andWhere('instance.suspensionState = \'none\'')
.andWhere('instance.isNotResponding = false') .andWhere('instance.isNotResponding = false')
.getRawOne() .getRawOne()

View file

@ -129,6 +129,7 @@ export class MetaEntityService {
mediaProxy: this.config.mediaProxy, mediaProxy: this.config.mediaProxy,
enableUrlPreview: instance.urlPreviewEnabled, enableUrlPreview: instance.urlPreviewEnabled,
noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local',
maxFileSize: this.config.maxFileSize,
}; };
return packed; return packed;

View file

@ -7,23 +7,23 @@ import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
export class RedisKVCache<T> { export class RedisKVCache<T> {
private redisClient: Redis.Redis; private readonly lifetime: number;
private name: string; private readonly memoryCache: MemoryKVCache<T>;
private lifetime: number; private readonly fetcher: (key: string) => Promise<T>;
private memoryCache: MemoryKVCache<T>; private readonly toRedisConverter: (value: T) => string;
private fetcher: (key: string) => Promise<T>; private readonly fromRedisConverter: (value: string) => T | undefined;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { constructor(
lifetime: RedisKVCache<T>['lifetime']; private redisClient: Redis.Redis,
memoryCacheLifetime: number; private name: string,
fetcher: RedisKVCache<T>['fetcher']; opts: {
toRedisConverter: RedisKVCache<T>['toRedisConverter']; lifetime: RedisKVCache<T>['lifetime'];
fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; memoryCacheLifetime: number;
}) { fetcher: RedisKVCache<T>['fetcher'];
this.redisClient = redisClient; toRedisConverter: RedisKVCache<T>['toRedisConverter'];
this.name = name; fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@ -55,7 +55,13 @@ export class RedisKVCache<T> {
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
if (cached == null) return undefined; if (cached == null) return undefined;
return this.fromRedisConverter(cached);
const value = this.fromRedisConverter(cached);
if (value !== undefined) {
this.memoryCache.set(key, value);
}
return value;
} }
@bindThis @bindThis
@ -66,6 +72,10 @@ export class RedisKVCache<T> {
/** /**
* fetcherを呼び出して結果をキャッシュ& * fetcherを呼び出して結果をキャッシュ&
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/ */
@bindThis @bindThis
public async fetch(key: string): Promise<T> { public async fetch(key: string): Promise<T> {
@ -77,14 +87,14 @@ export class RedisKVCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(key); const value = await this.fetcher(key);
this.set(key, value); await this.set(key, value);
return value; return value;
} }
@bindThis @bindThis
public async refresh(key: string) { public async refresh(key: string) {
const value = await this.fetcher(key); const value = await this.fetcher(key);
this.set(key, value); await this.set(key, value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@ -101,23 +111,23 @@ export class RedisKVCache<T> {
} }
export class RedisSingleCache<T> { export class RedisSingleCache<T> {
private redisClient: Redis.Redis; private readonly lifetime: number;
private name: string; private readonly memoryCache: MemorySingleCache<T>;
private lifetime: number; private readonly fetcher: () => Promise<T>;
private memoryCache: MemorySingleCache<T>; private readonly toRedisConverter: (value: T) => string;
private fetcher: () => Promise<T>; private readonly fromRedisConverter: (value: string) => T | undefined;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { constructor(
lifetime: RedisSingleCache<T>['lifetime']; private redisClient: Redis.Redis,
memoryCacheLifetime: number; private name: string,
fetcher: RedisSingleCache<T>['fetcher']; opts: {
toRedisConverter: RedisSingleCache<T>['toRedisConverter']; lifetime: number;
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; memoryCacheLifetime: number;
}) { fetcher: RedisSingleCache<T>['fetcher'];
this.redisClient = redisClient; toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
this.name = name; fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@ -149,7 +159,13 @@ export class RedisSingleCache<T> {
const cached = await this.redisClient.get(`singlecache:${this.name}`); const cached = await this.redisClient.get(`singlecache:${this.name}`);
if (cached == null) return undefined; if (cached == null) return undefined;
return this.fromRedisConverter(cached);
const value = this.fromRedisConverter(cached);
if (value !== undefined) {
this.memoryCache.set(value);
}
return value;
} }
@bindThis @bindThis
@ -160,6 +176,10 @@ export class RedisSingleCache<T> {
/** /**
* fetcherを呼び出して結果をキャッシュ& * fetcherを呼び出して結果をキャッシュ&
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/ */
@bindThis @bindThis
public async fetch(): Promise<T> { public async fetch(): Promise<T> {
@ -171,14 +191,14 @@ export class RedisSingleCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(); const value = await this.fetcher();
this.set(value); await this.set(value);
return value; return value;
} }
@bindThis @bindThis
public async refresh() { public async refresh() {
const value = await this.fetcher(); const value = await this.fetcher();
this.set(value); await this.set(value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@ -187,22 +207,12 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> { export class MemoryKVCache<T> {
/** private readonly cache = new Map<string, { date: number; value: T; }>();
* private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
* @deprecated
*/
public cache: Map<string, { date: number; value: T; }>;
private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout;
constructor(lifetime: MemoryKVCache<never>['lifetime']) { constructor(
this.cache = new Map(); private readonly lifetime: number,
this.lifetime = lifetime; ) {}
this.gcIntervalHandle = setInterval(() => {
this.gc();
}, 1000 * 60 * 3);
}
@bindThis @bindThis
/** /**
@ -287,10 +297,14 @@ export class MemoryKVCache<T> {
@bindThis @bindThis
public gc(): void { public gc(): void {
const now = Date.now(); const now = Date.now();
for (const [key, { date }] of this.cache.entries()) { for (const [key, { date }] of this.cache.entries()) {
if ((now - date) > this.lifetime) { // The map is ordered from oldest to youngest.
this.cache.delete(key); // We can stop once we find an entry that's still active, because all following entries must *also* be active.
} const age = now - date;
if (age < this.lifetime) break;
this.cache.delete(key);
} }
} }
@ -298,16 +312,19 @@ export class MemoryKVCache<T> {
public dispose(): void { public dispose(): void {
clearInterval(this.gcIntervalHandle); clearInterval(this.gcIntervalHandle);
} }
public get entries() {
return this.cache.entries();
}
} }
export class MemorySingleCache<T> { export class MemorySingleCache<T> {
private cachedAt: number | null = null; private cachedAt: number | null = null;
private value: T | undefined; private value: T | undefined;
private lifetime: number;
constructor(lifetime: MemorySingleCache<never>['lifetime']) { constructor(
this.lifetime = lifetime; private lifetime: number,
} ) {}
@bindThis @bindThis
public set(value: T): void { public set(value: T): void {

View file

@ -144,7 +144,9 @@ export interface Schema extends OfSchema {
readonly type?: TypeStringef; readonly type?: TypeStringef;
readonly nullable?: boolean; readonly nullable?: boolean;
readonly optional?: boolean; readonly optional?: boolean;
readonly prefixItems?: ReadonlyArray<Schema>;
readonly items?: Schema; readonly items?: Schema;
readonly unevaluatedItems?: Schema | boolean;
readonly properties?: Obj; readonly properties?: Obj;
readonly required?: ReadonlyArray<Extract<keyof NonNullable<this['properties']>, string>>; readonly required?: ReadonlyArray<Extract<keyof NonNullable<this['properties']>, string>>;
readonly description?: string; readonly description?: string;
@ -198,6 +200,7 @@ type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X
//type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never; //type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never; type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never;
type ArrayUnion<T> = T extends any ? Array<T> : never; type ArrayUnion<T> = T extends any ? Array<T> : never;
type ArrayToTuple<X extends ReadonlyArray<Schema>> = { [K in keyof X]: SchemaType<X[K]> };
type ObjectSchemaTypeDef<p extends Schema> = type ObjectSchemaTypeDef<p extends Schema> =
p['ref'] extends keyof typeof refs ? Packed<p['ref']> : p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
@ -232,6 +235,12 @@ export type SchemaTypeDef<p extends Schema> =
p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] : p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
never never
) : ) :
p['prefixItems'] extends ReadonlyArray<Schema> ? (
p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] :
p['items'] extends false ? ArrayToTuple<p['prefixItems']> :
p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> :
[...ArrayToTuple<p['prefixItems']>, ...unknown[]]
) :
p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] : p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
any[] any[]
) : ) :

View file

@ -85,7 +85,7 @@ export type MiNotification = {
/** /**
* body * body
*/ */
customBody: string | null; customBody: string;
/** /**
* header * header

View file

@ -253,6 +253,10 @@ export const packedMetaLiteSchema = {
optional: false, nullable: false, optional: false, nullable: false,
default: 'local', default: 'local',
}, },
maxFileSize: {
type: 'number',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
import { notificationTypes } from '@/types.js'; import { notificationTypes } from '@/types.js';
const baseSchema = { const baseSchema = {
@ -294,6 +295,7 @@ export const packedNotificationSchema = {
achievement: { achievement: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
enum: ACHIEVEMENT_TYPES,
}, },
}, },
}, { }, {
@ -311,11 +313,11 @@ export const packedNotificationSchema = {
}, },
header: { header: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: true,
}, },
icon: { icon: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: true,
}, },
}, },
}, { }, {

View file

@ -45,7 +45,7 @@ export class DeliverProcessorService {
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); // 1h
} }
@bindThis @bindThis

View file

@ -134,7 +134,7 @@ export class NodeinfoServerService {
return document; return document;
}; };
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m
fastify.get(nodeinfo2_1path, async (request, reply) => { fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2(21)); const base = await cache.fetch(() => nodeinfo2(21));

View file

@ -199,9 +199,18 @@ export class ApiCallService implements OnApplicationShutdown {
return; return;
} }
const [path] = await createTemp(); const [path, cleanup] = await createTemp();
await stream.pipeline(multipartData.file, fs.createWriteStream(path)); await stream.pipeline(multipartData.file, fs.createWriteStream(path));
// ファイルサイズが制限を超えていた場合
// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある
if (multipartData.file.truncated) {
cleanup();
reply.code(413);
reply.send();
return;
}
const fields = {} as Record<string, unknown>; const fields = {} as Record<string, unknown>;
for (const [k, v] of Object.entries(multipartData.fields)) { for (const [k, v] of Object.entries(multipartData.fields)) {
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;

View file

@ -49,7 +49,7 @@ export class ApiServerService {
fastify.register(multipart, { fastify.register(multipart, {
limits: { limits: {
fileSize: this.config.maxFileSize ?? 262144000, fileSize: this.config.maxFileSize,
files: 1, files: 1,
}, },
}); });

View file

@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
) { ) {
this.appCache = new MemoryKVCache<MiApp>(Infinity); this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w
} }
@bindThis @bindThis

View file

@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DeleteAccountService } from '@/core/DeleteAccountService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -33,9 +33,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userEntityService: UserEntityService, private deleteAccoountService: DeleteAccountService,
private queueService: QueueService,
private userSuspendService: UserSuspendService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId }); const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -48,22 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('cannot delete a root account'); throw new Error('cannot delete a root account');
} }
if (this.userEntityService.isLocalUser(user)) { await this.deleteAccoountService.deleteAccount(user);
// 物理削除する前にDelete activityを送信する
await this.userSuspendService.doPostSuspend(user).catch(err => {});
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
} else {
this.queueService.createDeleteAccountJob(user, {
soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
});
}
await this.usersRepository.update(user.id, {
isDeleted: true,
});
}); });
} }
} }

View file

@ -21,16 +21,15 @@ export const meta = {
items: { items: {
type: 'array', type: 'array',
optional: false, nullable: false, optional: false, nullable: false,
items: { prefixItems: [
anyOf: [ {
{ type: 'string',
type: 'string', },
}, {
{ type: 'number',
type: 'number', },
}, ],
], unevaluatedItems: false,
},
}, },
example: [[ example: [[
'example.com', 'example.com',

View file

@ -21,16 +21,15 @@ export const meta = {
items: { items: {
type: 'array', type: 'array',
optional: false, nullable: false, optional: false, nullable: false,
items: { prefixItems: [
anyOf: [ {
{ type: 'string',
type: 'string', },
}, {
{ type: 'number',
type: 'number', },
}, ],
], unevaluatedItems: false,
},
}, },
example: [[ example: [[
'example.com', 'example.com',

View file

@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = { export const meta = {
tags: ['admin', 'role'], tags: ['admin', 'role'],
@ -33,12 +34,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps) => { super(meta, paramDef, async (ps, me) => {
const before = await this.metaService.fetch(true);
await this.metaService.update({ await this.metaService.update({
policies: ps.policies, policies: ps.policies,
}); });
this.globalEventService.publishInternalEvent('policiesUpdated', ps.policies);
const after = await this.metaService.fetch(true);
this.globalEventService.publishInternalEvent('policiesUpdated', after.policies);
this.moderationLogService.log(me, 'updateServerSettings', {
before: before.policies,
after: after.policies,
});
}); });
} }
} }

View file

@ -3,18 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { IsNull, Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import type { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -38,13 +32,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
private roleService: RoleService, private roleService: RoleService,
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId }); const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -57,42 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('cannot suspend moderator account'); throw new Error('cannot suspend moderator account');
} }
await this.usersRepository.update(user.id, { await this.userSuspendService.suspend(user, me);
isSuspended: true,
});
this.moderationLogService.log(me, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.userSuspendService.doPostSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
})();
}); });
} }
@bindThis
private async unFollowAll(follower: MiUser) {
const followings = await this.followingsRepository.find({
where: {
followerId: follower.id,
followeeId: Not(IsNull()),
},
});
const jobs: RelationshipJobData[] = [];
for (const following of followings) {
if (following.followeeId && following.followerId) {
jobs.push({
from: { id: following.followerId },
to: { id: following.followeeId },
silent: true,
});
}
}
this.queueService.createUnfollowJob(jobs);
}
} }

View file

@ -6,7 +6,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -33,7 +32,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
private moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId }); const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -42,17 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('user not found'); throw new Error('user not found');
} }
await this.usersRepository.update(user.id, { await this.userSuspendService.unsuspend(user, me);
isSuspended: false,
});
this.moderationLogService.log(me, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
this.userSuspendService.doPostUnsuspend(user);
}); });
} }
} }

View file

@ -4,9 +4,11 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { FlashsRepository } from '@/models/_.js'; import type { FlashsRepository, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -44,17 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.flashsRepository) @Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository, private flashsRepository: FlashsRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
if (flash == null) { if (flash == null) {
throw new ApiError(meta.errors.noSuchFlash); throw new ApiError(meta.errors.noSuchFlash);
} }
if (flash.userId !== me.id) {
if (!await this.roleService.isModerator(me) && flash.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied); throw new ApiError(meta.errors.accessDenied);
} }
await this.flashsRepository.delete(flash.id); await this.flashsRepository.delete(flash.id);
if (flash.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: flash.userId });
this.moderationLogService.log(me, 'deleteFlash', {
flashId: flash.id,
flashUserId: flash.userId,
flashUserUsername: user.username,
flash,
});
}
}); });
} }
} }

View file

@ -5,8 +5,10 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryPostsRepository } from '@/models/_.js'; import type { GalleryPostsRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -22,6 +24,12 @@ export const meta = {
code: 'NO_SUCH_POST', code: 'NO_SUCH_POST',
id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5', id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5',
}, },
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'c86e09de-1c48-43ac-a435-1c7e42ed4496',
},
}, },
} as const; } as const;
@ -38,18 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.galleryPostsRepository) @Inject(DI.galleryPostsRepository)
private galleryPostsRepository: GalleryPostsRepository, private galleryPostsRepository: GalleryPostsRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const post = await this.galleryPostsRepository.findOneBy({ const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId });
id: ps.postId,
userId: me.id,
});
if (post == null) { if (post == null) {
throw new ApiError(meta.errors.noSuchPost); throw new ApiError(meta.errors.noSuchPost);
} }
if (!await this.roleService.isModerator(me) && post.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.galleryPostsRepository.delete(post.id); await this.galleryPostsRepository.delete(post.id);
if (post.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: post.userId });
this.moderationLogService.log(me, 'deleteGalleryPost', {
postId: post.id,
postUserId: post.userId,
postUserUsername: user.username,
post,
});
}
}); });
} }
} }

View file

@ -4,9 +4,11 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { PagesRepository } from '@/models/_.js'; import type { PagesRepository, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -44,17 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.pagesRepository) @Inject(DI.pagesRepository)
private pagesRepository: PagesRepository, private pagesRepository: PagesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
if (page == null) { if (page == null) {
throw new ApiError(meta.errors.noSuchPage); throw new ApiError(meta.errors.noSuchPage);
} }
if (page.userId !== me.id) {
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied); throw new ApiError(meta.errors.accessDenied);
} }
await this.pagesRepository.delete(page.id); await this.pagesRepository.delete(page.id);
if (page.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
this.moderationLogService.log(me, 'deletePage', {
pageId: page.id,
pageUserId: page.userId,
pageUserUsername: user.username,
page,
});
}
}); });
} }
} }

View file

@ -20,6 +20,8 @@ import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events'; import type { EventEmitter } from 'events';
import type Channel from './channel.js'; import type Channel from './channel.js';
const MAX_CHANNELS_PER_CONNECTION = 32;
/** /**
* Main stream connection * Main stream connection
*/ */
@ -255,6 +257,10 @@ export default class Connection {
*/ */
@bindThis @bindThis
public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
return;
}
const channelService = this.channelsService.getChannelService(channel); const channelService = this.channelsService.getChannelService(channel);
if (channelService.requireCredential && this.user == null) { if (channelService.requireCredential && this.user == null) {

View file

@ -12,6 +12,7 @@ import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityServi
import { isJsonObject } from '@/misc/json-value.js'; import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js'; import Channel, { type MiChannelService } from '../channel.js';
import { reversiUpdateKeys } from 'misskey-js';
class ReversiGameChannel extends Channel { class ReversiGameChannel extends Channel {
public readonly chName = 'reversiGame'; public readonly chName = 'reversiGame';
@ -46,8 +47,9 @@ class ReversiGameChannel extends Channel {
break; break;
case 'updateSettings': case 'updateSettings':
if (!isJsonObject(body)) return; if (!isJsonObject(body)) return;
if (typeof body.key !== 'string') return; if (!this.reversiService.isValidReversiUpdateKey(body.key)) return;
if (!isJsonObject(body.value)) return; if (!this.reversiService.isValidReversiUpdateValue(body.key, body.value)) return;
this.updateSettings(body.key, body.value); this.updateSettings(body.key, body.value);
break; break;
case 'cancel': case 'cancel':
@ -64,7 +66,7 @@ class ReversiGameChannel extends Channel {
} }
@bindThis @bindThis
private async updateSettings(key: string, value: JsonObject) { private async updateSettings<K extends typeof reversiUpdateKeys[number]>(key: K, value: MiReversiGame[K]) {
if (this.user == null) return; if (this.user == null) return;
this.reversiService.updateSettings(this.gameId!, this.user, key, value); this.reversiService.updateSettings(this.gameId!, this.user, key, value);

View file

@ -166,7 +166,7 @@
if (!errorsElement) { if (!errorsElement) {
document.body.innerHTML = ` document.body.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path> <path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path> <path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
@ -176,10 +176,10 @@
<span class="button-label-big">Reload / リロード</span> <span class="button-label-big">Reload / リロード</span>
</button> </button>
<p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります</b></p> <p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります</b></p>
<p>Clear the browser cache / ブラウザのキャッシュをクリアする</p>
<p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p> <p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p>
<p>Disable an adblocker / アドブロッカーを無効にする</p> <p>Disable an adblocker / アドブロッカーを無効にする</p>
<p>&#40;Tor Browser&#41; Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p> <p>Clear the browser cache / ブラウザのキャッシュをクリアする</p>
<p>&#40;Tor Browser&#41; Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p>
<details style="color: #86b300;"> <details style="color: #86b300;">
<summary>Other options / その他のオプション</summary> <summary>Other options / その他のオプション</summary>
<a href="/flush"> <a href="/flush">
@ -212,7 +212,7 @@
<summary> <summary>
<code>ERROR CODE: ${code}</code> <code>ERROR CODE: ${code}</code>
</summary> </summary>
<code>${JSON.stringify(details)}</code>`; <code>${details.toString()} ${JSON.stringify(details)}</code>`;
errorsElement.appendChild(detailsElement); errorsElement.appendChild(detailsElement);
addStyle(` addStyle(`
* { * {
@ -320,6 +320,6 @@
#errorInfo { #errorInfo {
width: 50%; width: 50%;
} }
}`) }`);
} }
})(); })();

View file

@ -36,8 +36,6 @@ html
link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl) link(rel='prefetch' href=notFoundImageUrl)
//- https://github.com/misskey-dev/misskey/issues/9842
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0')
link(rel='modulepreload' href=`/vite/${clientEntry.file}`) link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
if !config.clientManifestExists if !config.clientManifestExists

View file

@ -96,6 +96,10 @@ export const moderationLogTypes = [
'createAbuseReportNotificationRecipient', 'createAbuseReportNotificationRecipient',
'updateAbuseReportNotificationRecipient', 'updateAbuseReportNotificationRecipient',
'deleteAbuseReportNotificationRecipient', 'deleteAbuseReportNotificationRecipient',
'deleteAccount',
'deletePage',
'deleteFlash',
'deleteGalleryPost',
] as const; ] as const;
export type ModerationLogPayloads = { export type ModerationLogPayloads = {
@ -314,6 +318,29 @@ export type ModerationLogPayloads = {
recipientId: string; recipientId: string;
recipient: any; recipient: any;
}; };
deleteAccount: {
userId: string;
userUsername: string;
userHost: string | null;
};
deletePage: {
pageId: string;
pageUserId: string;
pageUserUsername: string;
page: any;
};
deleteFlash: {
flashId: string;
flashUserId: string;
flashUserUsername: string;
flash: any;
};
deleteGalleryPost: {
postId: string;
postUserId: string;
postUserUsername: string;
post: any;
};
}; };
export type Serialized<T> = { export type Serialized<T> = {

View file

@ -6,6 +6,8 @@
// https://vitejs.dev/config/build-options.html#build-modulepreload // https://vitejs.dev/config/build-options.html#build-modulepreload
import 'vite/modulepreload-polyfill'; import 'vite/modulepreload-polyfill';
import '@tabler/icons-webfont/dist/tabler-icons.scss';
import '@/style.scss'; import '@/style.scss';
import { mainBoot } from '@/boot/main-boot.js'; import { mainBoot } from '@/boot/main-boot.js';
import { subBoot } from '@/boot/sub-boot.js'; import { subBoot } from '@/boot/sub-boot.js';

View file

@ -3,11 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
// devモードで起動される際index.htmlを使うときはrouterが暴発してしまってうまく読み込めない。
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
import '@tabler/icons-webfont/dist/tabler-icons.scss';
await main(); await main();
import('@/_boot_.js'); import('@/_boot_.js');

View file

@ -231,17 +231,18 @@ export async function mainBoot() {
claimAchievement('client60min'); claimAchievement('client60min');
}, 1000 * 60 * 60); }, 1000 * 60 * 60);
const lastUsed = miLocalStorage.getItem('lastUsed'); // 邪魔
if (lastUsed) { //const lastUsed = miLocalStorage.getItem('lastUsed');
const lastUsedDate = parseInt(lastUsed, 10); //if (lastUsed) {
// 二時間以上前なら // const lastUsedDate = parseInt(lastUsed, 10);
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { // // 二時間以上前なら
toast(i18n.tsx.welcomeBackWithName({ // if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
name: $i.name || $i.username, // toast(i18n.tsx.welcomeBackWithName({
})); // name: $i.name || $i.username,
} // }));
} // }
miLocalStorage.setItem('lastUsed', Date.now().toString()); //}
//miLocalStorage.setItem('lastUsed', Date.now().toString());
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');

View file

@ -39,7 +39,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
user: Misskey.entities.UserDetailed; user: Misskey.entities.UserLite;
initialComment?: string; initialComment?: string;
}>(); }>();

View file

@ -171,11 +171,11 @@ function onMousedown(evt: MouseEvent): void {
background: var(--accent); background: var(--accent);
&:not(:disabled):hover { &:not(:disabled):hover {
background: var(--X8); background: hsl(from var(--accent) h s calc(l + 5));
} }
&:not(:disabled):active { &:not(:disabled):active {
background: var(--X8); background: hsl(from var(--accent) h s calc(l + 5));
} }
} }
@ -220,11 +220,11 @@ function onMousedown(evt: MouseEvent): void {
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
&:not(:disabled):hover { &:not(:disabled):hover {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
&:not(:disabled):active { &:not(:disabled):active {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
} }

View file

@ -117,7 +117,7 @@ const bannerStyle = computed(() => {
left: 0; left: 0;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
} }
> .name { > .name {

View file

@ -216,7 +216,7 @@ onUnmounted(() => {
left: 0; left: 0;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
> .fadeLabel { > .fadeLabel {
display: inline-block; display: inline-block;

View file

@ -200,6 +200,7 @@ import { host } from '@/config.js';
import { isEnabledUrlPreview } from '@/instance.js'; import { isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js'; import { type Keymap } from '@/scripts/hotkey.js';
import { focusPrev, focusNext } from '@/scripts/focus.js'; import { focusPrev, focusNext } from '@/scripts/focus.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -242,14 +243,7 @@ if (noteViewInterruptors.length > 0) {
}); });
} }
const isRenote = ( const isRenote = Misskey.note.isPureRenote(note.value);
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
const rootEl = shallowRef<HTMLElement>(); const rootEl = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>();
@ -257,7 +251,7 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false); const showContent = ref(false);
@ -865,7 +859,7 @@ function emitUpdReaction(emoji: string, delta: number) {
z-index: 2; z-index: 2;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
&:hover > .collapsedLabel { &:hover > .collapsedLabel {
background: var(--panelHighlight); background: var(--panelHighlight);

View file

@ -235,6 +235,7 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { isEnabledUrlPreview } from '@/instance.js'; import { isEnabledUrlPreview } from '@/instance.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
import { type Keymap } from '@/scripts/hotkey.js'; import { type Keymap } from '@/scripts/hotkey.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -267,14 +268,7 @@ if (noteViewInterruptors.length > 0) {
}); });
} }
const isRenote = ( const isRenote = Misskey.note.isPureRenote(note.value);
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
const rootEl = shallowRef<HTMLElement>(); const rootEl = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>();
@ -282,7 +276,7 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false); const showContent = ref(false);

View file

@ -62,7 +62,7 @@ onUnmounted(() => {
left: 0; left: 0;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
> .fadeLabel { > .fadeLabel {
display: inline-block; display: inline-block;

View file

@ -245,7 +245,7 @@ const submitText = computed((): string => {
}); });
const textLength = computed((): number => { const textLength = computed((): number => {
return (text.value + imeText.value).trim().length; return (text.value + imeText.value).length;
}); });
const maxTextLength = computed((): number => { const maxTextLength = computed((): number => {
@ -1128,13 +1128,13 @@ defineExpose({
&:not(:disabled):hover { &:not(:disabled):hover {
> .inner { > .inner {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
} }
&:not(:disabled):active { &:not(:disabled):active {
> .inner { > .inner {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
} }
} }

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="$style.preview"> <div :class="$style.preview">
<div :class="$style.preview__content1"> <div>
<MkInput v-model="text"> <MkInput v-model="text">
<template #label>Text</template> <template #label>Text</template>
</MkInput> </MkInput>

View file

@ -4,25 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkA v-adaptive-bg :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> <MkA :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
<div :class="$style.title"> <template v-if="forModeration">
<span :class="$style.icon"> <i v-if="role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--success)"></i>
<template v-if="role.iconUrl"> <i v-else class="ti ti-lock" :class="$style.icon" style="color: var(--warn)"></i>
<img :class="$style.badge" :src="role.iconUrl"/> </template>
<div v-adaptive-bg class="_panel" :class="$style.body">
<div :class="$style.bodyTitle">
<span :class="$style.bodyIcon">
<template v-if="role.iconUrl">
<img :class="$style.bodyBadge" :src="role.iconUrl"/>
</template>
<template v-else>
<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
</template>
</span>
<span :class="$style.bodyName">{{ role.name }}</span>
<template v-if="detailed">
<span v-if="role.target === 'manual'" :class="$style.bodyUsers">{{ role.usersCount }} users</span>
<span v-else-if="role.target === 'conditional'" :class="$style.bodyUsers">? users</span>
</template> </template>
<template v-else> </div>
<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i> <div :class="$style.bodyDescription">{{ role.description }}</div>
<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
<i v-else class="ti ti-user" style="opacity: 0.7;"></i>
</template>
</span>
<span :class="$style.name">{{ role.name }}</span>
<template v-if="detailed">
<span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
<span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span>
</template>
</div> </div>
<div :class="$style.description">{{ role.description }}</div>
</MkA> </MkA>
</template> </template>
@ -42,34 +49,44 @@ const props = withDefaults(defineProps<{
<style lang="scss" module> <style lang="scss" module>
.root { .root {
display: block;
padding: 16px 20px;
border-left: solid 6px var(--color);
}
.title {
display: flex; display: flex;
align-items: center;
} }
.icon { .icon {
margin: 0 12px;
}
.body {
display: block;
padding: 16px 20px;
flex: 1;
border-left: solid 6px var(--color);
}
.bodyTitle {
display: flex;
}
.bodyIcon {
margin-right: 8px; margin-right: 8px;
} }
.badge { .bodyBadge {
height: 1.3em; height: 1.3em;
vertical-align: -20%; vertical-align: -20%;
} }
.name { .bodyName {
font-weight: bold; font-weight: bold;
} }
.users { .bodyUsers {
margin-left: auto; margin-left: auto;
opacity: 0.7; opacity: 0.7;
} }
.description { .bodyDescription {
opacity: 0.7; opacity: 0.7;
font-size: 85%; font-size: 85%;
} }

View file

@ -62,7 +62,7 @@ const collapsed = ref(isLong);
left: 0; left: 0;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
> .fadeLabel { > .fadeLabel {
display: inline-block; display: inline-block;

View file

@ -21,7 +21,7 @@ import { host as hostRaw } from '@/config.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
defineProps<{ defineProps<{
user: Misskey.entities.User; user: Misskey.entities.UserLite;
detail?: boolean; detail?: boolean;
}>(); }>();

View file

@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="showDecoration"> <template v-if="showDecoration">
<img <img
v-for="decoration in decorations ?? user.avatarDecorations" v-for="decoration in decorations ?? user.avatarDecorations"
:class="[$style.decoration]" :class="[$style.decoration, { [$style.decorationBlink]: decoration.blink }]"
:src="getDecorationUrl(decoration)" :src="getDecorationUrl(decoration)"
:style="{ :style="{
rotate: getDecorationAngle(decoration), rotate: getDecorationAngle(decoration),
@ -60,7 +60,7 @@ const props = withDefaults(defineProps<{
link?: boolean; link?: boolean;
preview?: boolean; preview?: boolean;
indicator?: boolean; indicator?: boolean;
decorations?: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>[]; decorations?: (Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'> & { blink?: boolean; })[];
forceShowDecoration?: boolean; forceShowDecoration?: boolean;
}>(), { }>(), {
target: null, target: null,
@ -330,4 +330,17 @@ watch(() => props.user.avatarBlurhash, () => {
width: 200%; width: 200%;
pointer-events: none; pointer-events: none;
} }
.decorationBlink {
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% {
filter: brightness(2);
}
50% {
filter: brightness(1);
}
}
</style> </style>

View file

@ -72,10 +72,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar"> <img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@syuilo</span> <span :class="$style.contributorUsername">@syuilo</span>
</a> </a>
<a href="https://github.com/tamaina" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/7973572?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@tamaina</span>
</a>
<a href="https://github.com/acid-chicken" target="_blank" :class="$style.contributor"> <a href="https://github.com/acid-chicken" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar"> <img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@acid-chicken</span> <span :class="$style.contributorUsername">@acid-chicken</span>
@ -267,6 +263,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'Macop', name: 'Macop',
icon: 'https://assets.misskey-hub.net/patrons/ee052bf550014d36a643ce3dce595640.jpg', icon: 'https://assets.misskey-hub.net/patrons/ee052bf550014d36a643ce3dce595640.jpg',
}, {
name: 'なっかあ',
icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg',
}]; }];
const patrons = [ const patrons = [

View file

@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<MkInfo v-if="user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> <MkInfo v-if="['instance.actor', 'relay.actor'].includes(user.username)">{{ i18n.ts.isSystemAccount }}</MkInfo>
<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="900"> <MkSpacer :contentMax="900">
<div :class="$style.root" class="_gaps"> <div :class="$style.root" class="_gaps">
<div :class="$style.subMenus" class="_gaps"> <div :class="$style.subMenus" class="_gaps">
<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton> <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton>
</div> </div>
<div :class="$style.inputs" class="_gaps"> <div :class="$style.inputs" class="_gaps">

View file

@ -21,12 +21,12 @@ SPDX-License-Identifier: AGPL-3.0-only
].includes(log.type), ].includes(log.type),
[$style.logYellow]: [ [$style.logYellow]: [
'markSensitiveDriveFile', 'markSensitiveDriveFile',
'resetPassword' 'resetPassword',
'suspendRemoteInstance',
].includes(log.type), ].includes(log.type),
[$style.logRed]: [ [$style.logRed]: [
'suspend', 'suspend',
'deleteRole', 'deleteRole',
'suspendRemoteInstance',
'deleteGlobalAnnouncement', 'deleteGlobalAnnouncement',
'deleteUserAnnouncement', 'deleteUserAnnouncement',
'deleteCustomEmoji', 'deleteCustomEmoji',
@ -36,6 +36,10 @@ SPDX-License-Identifier: AGPL-3.0-only
'deleteAvatarDecoration', 'deleteAvatarDecoration',
'deleteSystemWebhook', 'deleteSystemWebhook',
'deleteAbuseReportNotificationRecipient', 'deleteAbuseReportNotificationRecipient',
'deleteAccount',
'deletePage',
'deleteFlash',
'deleteGalleryPost',
].includes(log.type) ].includes(log.type)
}" }"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b> >{{ i18n.ts._moderationLogTypes[log.type] }}</b>
@ -72,6 +76,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> <span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
<span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span> <span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> <span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
<span v-else-if="log.type === 'deleteAccount'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'deletePage'">: @{{ log.info.pageUserUsername }}</span>
<span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span>
<span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span>
</template> </template>
<template #icon> <template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/> <MkAvatar :user="log.user" :class="$style.avatar"/>
@ -143,7 +151,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
<template v-else-if="log.type === 'updateRemoteInstanceNote'"> <template v-else-if="log.type === 'updateRemoteInstanceNote'">
<div>{{ i18n.ts.user }}: {{ log.info.userId }}</div>
<div :class="$style.diff"> <div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/> <CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div> </div>

View file

@ -36,7 +36,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import XChart from './overview.queue.chart.vue'; import XChart from './overview.queue.chart.vue';
import type { ApQueueDomain } from '@/pages/admin/queue.vue';
import number from '@/filters/number.js'; import number from '@/filters/number.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
@ -52,10 +54,10 @@ const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
const props = defineProps<{ const props = defineProps<{
domain: string; domain: ApQueueDomain;
}>(); }>();
const onStats = (stats) => { function onStats(stats: Misskey.entities.QueueStats) {
activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
active.value = stats[props.domain].active; active.value = stats[props.domain].active;
delayed.value = stats[props.domain].delayed; delayed.value = stats[props.domain].delayed;
@ -65,13 +67,13 @@ const onStats = (stats) => {
chartActive.value.pushData(stats[props.domain].active); chartActive.value.pushData(stats[props.domain].active);
chartDelayed.value.pushData(stats[props.domain].delayed); chartDelayed.value.pushData(stats[props.domain].delayed);
chartWaiting.value.pushData(stats[props.domain].waiting); chartWaiting.value.pushData(stats[props.domain].waiting);
}; }
const onStatsLog = (statsLog) => { function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) {
const dataProcess = []; const dataProcess: Misskey.entities.QueueStats[ApQueueDomain]['activeSincePrevTick'][] = [];
const dataActive = []; const dataActive: Misskey.entities.QueueStats[ApQueueDomain]['active'][] = [];
const dataDelayed = []; const dataDelayed: Misskey.entities.QueueStats[ApQueueDomain]['delayed'][] = [];
const dataWaiting = []; const dataWaiting: Misskey.entities.QueueStats[ApQueueDomain]['waiting'][] = [];
for (const stats of [...statsLog].reverse()) { for (const stats of [...statsLog].reverse()) {
dataProcess.push(stats[props.domain].activeSincePrevTick); dataProcess.push(stats[props.domain].activeSincePrevTick);
@ -84,7 +86,7 @@ const onStatsLog = (statsLog) => {
chartActive.value.setData(dataActive); chartActive.value.setData(dataActive);
chartDelayed.value.setData(dataDelayed); chartDelayed.value.setData(dataDelayed);
chartWaiting.value.setData(dataWaiting); chartWaiting.value.setData(dataWaiting);
}; }
onMounted(() => { onMounted(() => {
connection.on('stats', onStats); connection.on('stats', onStats);

View file

@ -49,7 +49,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import XChart from './queue.chart.chart.vue'; import XChart from './queue.chart.chart.vue';
import type { ApQueueDomain } from '@/pages/admin/queue.vue';
import number from '@/filters/number.js'; import number from '@/filters/number.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
@ -62,17 +64,17 @@ const activeSincePrevTick = ref(0);
const active = ref(0); const active = ref(0);
const delayed = ref(0); const delayed = ref(0);
const waiting = ref(0); const waiting = ref(0);
const jobs = ref<(string | number)[][]>([]); const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]);
const chartProcess = shallowRef<InstanceType<typeof XChart>>(); const chartProcess = shallowRef<InstanceType<typeof XChart>>();
const chartActive = shallowRef<InstanceType<typeof XChart>>(); const chartActive = shallowRef<InstanceType<typeof XChart>>();
const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
const props = defineProps<{ const props = defineProps<{
domain: string; domain: ApQueueDomain;
}>(); }>();
const onStats = (stats) => { function onStats(stats: Misskey.entities.QueueStats) {
activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
active.value = stats[props.domain].active; active.value = stats[props.domain].active;
delayed.value = stats[props.domain].delayed; delayed.value = stats[props.domain].delayed;
@ -82,13 +84,13 @@ const onStats = (stats) => {
chartActive.value.pushData(stats[props.domain].active); chartActive.value.pushData(stats[props.domain].active);
chartDelayed.value.pushData(stats[props.domain].delayed); chartDelayed.value.pushData(stats[props.domain].delayed);
chartWaiting.value.pushData(stats[props.domain].waiting); chartWaiting.value.pushData(stats[props.domain].waiting);
}; }
const onStatsLog = (statsLog) => { function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) {
const dataProcess = []; const dataProcess: Misskey.entities.QueueStats[ApQueueDomain]['activeSincePrevTick'][] = [];
const dataActive = []; const dataActive: Misskey.entities.QueueStats[ApQueueDomain]['active'][] = [];
const dataDelayed = []; const dataDelayed: Misskey.entities.QueueStats[ApQueueDomain]['delayed'][] = [];
const dataWaiting = []; const dataWaiting: Misskey.entities.QueueStats[ApQueueDomain]['waiting'][] = [];
for (const stats of [...statsLog].reverse()) { for (const stats of [...statsLog].reverse()) {
dataProcess.push(stats[props.domain].activeSincePrevTick); dataProcess.push(stats[props.domain].activeSincePrevTick);
@ -101,14 +103,12 @@ const onStatsLog = (statsLog) => {
chartActive.value.setData(dataActive); chartActive.value.setData(dataActive);
chartDelayed.value.setData(dataDelayed); chartDelayed.value.setData(dataDelayed);
chartWaiting.value.setData(dataWaiting); chartWaiting.value.setData(dataWaiting);
}; }
onMounted(() => { onMounted(() => {
if (props.domain === 'inbox' || props.domain === 'deliver') { misskeyApi(`admin/queue/${props.domain}-delayed`).then(result => {
misskeyApi(`admin/queue/${props.domain}-delayed`).then(result => { jobs.value = result;
jobs.value = result; });
});
}
connection.on('stats', onStats); connection.on('stats', onStats);
connection.on('statsLog', onStatsLog); connection.on('statsLog', onStatsLog);

View file

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed, type Ref } from 'vue';
import XQueue from './queue.chart.vue'; import XQueue from './queue.chart.vue';
import XHeader from './_header_.vue'; import XHeader from './_header_.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -25,7 +25,9 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
const tab = ref('deliver'); export type ApQueueDomain = 'deliver' | 'inbox';
const tab: Ref<ApQueueDomain> = ref('deliver');
function clear() { function clear() {
os.confirm({ os.confirm({

View file

@ -33,11 +33,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect> </MkSelect>
</div> </div>
<div :class="$style.inputs"> <div :class="$style.inputs">
<MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()"> <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false">
<template #prefix>@</template> <template #prefix>@</template>
<template #label>{{ i18n.ts.username }}</template> <template #label>{{ i18n.ts.username }}</template>
</MkInput> </MkInput>
<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'">
<template #prefix>@</template> <template #prefix>@</template>
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
</MkInput> </MkInput>

View file

@ -12,19 +12,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ avatarDecoration.name }}</template> <template #label>{{ avatarDecoration.name }}</template>
<template #caption>{{ avatarDecoration.description }}</template> <template #caption>{{ avatarDecoration.description }}</template>
<div class="_gaps_m"> <div :class="$style.editorRoot">
<MkInput v-model="avatarDecoration.name"> <div :class="$style.editorWrapper">
<template #label>{{ i18n.ts.name }}</template> <div :class="$style.preview">
</MkInput> <div :class="[$style.previewItem, $style.light]">
<MkTextarea v-model="avatarDecoration.description"> <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[avatarDecoration]" forceShowDecoration/>
<template #label>{{ i18n.ts.description }}</template> </div>
</MkTextarea> <div :class="[$style.previewItem, $style.dark]">
<MkInput v-model="avatarDecoration.url"> <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[avatarDecoration]" forceShowDecoration/>
<template #label>{{ i18n.ts.imageUrl }}</template> </div>
</MkInput> </div>
<div class="buttons _buttons"> <div class="_gaps_m">
<MkButton class="button" inline primary @click="save(avatarDecoration)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkInput v-model="avatarDecoration.name">
<MkButton v-if="avatarDecoration.id != null" class="button" inline danger @click="del(avatarDecoration)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> <template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="avatarDecoration.description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkInput v-model="avatarDecoration.url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<div class="_buttons">
<MkButton inline primary @click="save(avatarDecoration)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="avatarDecoration.id != null" inline danger @click="del(avatarDecoration)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</div> </div>
</div> </div>
</MkFolder> </MkFolder>
@ -39,6 +51,7 @@ import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import { signinRequired } from '@/account.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -47,6 +60,8 @@ import MkFolder from '@/components/MkFolder.vue';
const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]); const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]);
const $i = signinRequired();
function add() { function add() {
avatarDecorations.value.unshift({ avatarDecorations.value.unshift({
_id: Math.random().toString(36), _id: Math.random().toString(36),
@ -99,3 +114,55 @@ definePageMetadata(() => ({
icon: 'ti ti-sparkles', icon: 'ti ti-sparkles',
})); }));
</script> </script>
<style lang="scss" module>
.editorRoot {
container: editor / inline-size;
}
.editorWrapper {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
gap: var(--margin);
}
.preview {
display: grid;
place-items: center;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: var(--margin);
}
.previewItem {
width: 100%;
height: 100%;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
&.light {
background: #eee;
}
&.dark {
background: #222;
}
}
@container editor (min-width: 600px) {
.editorWrapper {
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: calc(var(--margin) * 2);
}
.preview {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View file

@ -310,7 +310,7 @@ definePageMetadata(() => ({
left: 0; left: 0;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
} }
.bannerStatus { .bannerStatus {

View file

@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
<MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
<MkButton v-if="$i && $i.id !== flash.user.id" class="button" rounded @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></MkButton>
</div> </div>
</div> </div>
</div> </div>
@ -61,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef } from 'vue'; import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { Interpreter, Parser, values } from '@syuilo/aiscript'; import { Interpreter, Parser, values } from '@syuilo/aiscript';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -79,6 +80,7 @@ import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
const props = defineProps<{ const props = defineProps<{
@ -229,6 +231,53 @@ async function run() {
} }
} }
function reportAbuse() {
if (!flash.value) return;
const pageUrl = `${url}/play/${flash.value.id}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: flash.value.user,
initialComment: `Play: ${pageUrl}\n-----\n`,
}, {
closed: () => dispose(),
});
}
function showMenu(ev: MouseEvent) {
if (!flash.value) return;
const menu: MenuItem[] = [
...($i && $i.id !== flash.value.userId ? [
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
},
...($i.isModerator || $i.isAdmin ? [
{
type: 'divider' as const,
},
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !flash.value) return;
os.apiWithDialog('flash/delete', { flashId: flash.value.id });
}),
},
] : []),
] : []),
];
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
function reset() { function reset() {
if (aiscript.value) aiscript.value.abort(); if (aiscript.value) aiscript.value.abort();
started.value = false; started.value = false;

View file

@ -31,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
<button v-if="$i && $i.id !== post.user.id" v-click-anime class="_button" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button>
</div> </div>
</div> </div>
<div class="user"> <div class="user">
@ -62,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, ref } from 'vue'; import { computed, watch, ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -79,6 +80,7 @@ import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import { MenuItem } from '@/types/menu';
const router = useRouter(); const router = useRouter();
@ -153,13 +155,56 @@ function edit() {
router.push(`/gallery/${post.value.id}/edit`); router.push(`/gallery/${post.value.id}/edit`);
} }
function reportAbuse() {
if (!post.value) return;
const pageUrl = `${url}/gallery/${post.value.id}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: post.value.user,
initialComment: `Post: ${pageUrl}\n-----\n`,
}, {
closed: () => dispose(),
});
}
function showMenu(ev: MouseEvent) {
if (!post.value) return;
const menu: MenuItem[] = [
...($i && $i.id !== post.value.userId ? [
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
},
...($i.isModerator || $i.isAdmin ? [
{
type: 'divider' as const,
},
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !post.value) return;
os.apiWithDialog('gallery/posts/delete', { postId: post.value.id });
}),
},
] : []),
] : []),
];
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
watch(() => props.postId, fetchPost, { immediate: true }); watch(() => props.postId, fetchPost, { immediate: true });
const headerActions = computed(() => [{ const headerActions = computed(() => []);
icon: 'ti ti-pencil',
text: i18n.ts.edit,
handler: edit,
}]);
const headerTabs = computed(() => []); const headerTabs = computed(() => []);

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { i18n } from '@/i18n.js';
export function getPageBlockList() {
return [
{ value: 'section', text: i18n.ts._pages.blocks.section },
{ value: 'text', text: i18n.ts._pages.blocks.text },
{ value: 'image', text: i18n.ts._pages.blocks.image },
{ value: 'note', text: i18n.ts._pages.blocks.note },
];
}

View file

@ -29,6 +29,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { getPageBlockList } from '@/pages/page-editor/common.js';
const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue'));
@ -53,11 +54,9 @@ watch(children, () => {
deep: true, deep: true,
}); });
const getPageBlockList = inject<(any) => any>('getPageBlockList');
async function rename() { async function rename() {
const { canceled, result: title } = await os.inputText({ const { canceled, result: title } = await os.inputText({
title: 'Enter title', title: i18n.ts._pages.enterSectionTitle,
default: props.modelValue.title, default: props.modelValue.title,
}); });
if (canceled) return; if (canceled) return;

View file

@ -77,6 +77,7 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { mainRouter } from '@/router/main.js'; import { mainRouter } from '@/router/main.js';
import { getPageBlockList } from '@/pages/page-editor/common.js';
const props = defineProps<{ const props = defineProps<{
initPageId?: string; initPageId?: string;
@ -101,7 +102,6 @@ const alignCenter = ref(false);
const hideTitleWhenPinned = ref(false); const hideTitleWhenPinned = ref(false);
provide('readonly', readonly.value); provide('readonly', readonly.value);
provide('getPageBlockList', getPageBlockList);
watch(eyeCatchingImageId, async () => { watch(eyeCatchingImageId, async () => {
if (eyeCatchingImageId.value == null) { if (eyeCatchingImageId.value == null) {
@ -216,15 +216,6 @@ async function add() {
content.value.push({ id, type }); content.value.push({ id, type });
} }
function getPageBlockList() {
return [
{ value: 'section', text: i18n.ts._pages.blocks.section },
{ value: 'text', text: i18n.ts._pages.blocks.text },
{ value: 'image', text: i18n.ts._pages.blocks.image },
{ value: 'note', text: i18n.ts._pages.blocks.note },
];
}
function setEyeCatchingImage(img) { function setEyeCatchingImage(img) {
selectFile(img.currentTarget ?? img.target, null).then(file => { selectFile(img.currentTarget ?? img.target, null).then(file => {
eyeCatchingImageId.value = file.id; eyeCatchingImageId.value = file.id;

View file

@ -62,8 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
</div> </div>
<div :class="$style.other"> <div :class="$style.other">
<MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA>
<button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
<button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button> <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
<button v-if="$i" v-click-anime class="_button" :class="$style.generalActionButton" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button>
</div> </div>
</div> </div>
<div :class="$style.pageUser"> <div :class="$style.pageUser">
@ -78,14 +80,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
<div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
</div> </div>
<div :class="$style.pageLinks">
<MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
<template v-if="$i && $i.id === page.userId">
<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
<button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button>
</template>
</div>
</div> </div>
<MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkAd :prefer="['horizontal', 'horizontal-big']"/>
<MkContainer :max-height="300" :foldable="true" class="other"> <MkContainer :max-height="300" :foldable="true" class="other">
@ -104,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, ref } from 'vue'; import { computed, watch, ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XPage from '@/components/page/page.vue'; import XPage from '@/components/page/page.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -126,6 +120,10 @@ import { isSupportShare } from '@/scripts/navigator.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js';
import { MenuItem } from '@/types/menu';
const router = useRouter();
const props = defineProps<{ const props = defineProps<{
pageName: string; pageName: string;
@ -242,6 +240,69 @@ function pin(pin) {
}); });
} }
function reportAbuse() {
if (!page.value) return;
const pageUrl = `${url}/@${props.username}/pages/${props.pageName}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: page.value.user,
initialComment: `Page: ${pageUrl}\n-----\n`,
}, {
closed: () => dispose(),
});
}
function showMenu(ev: MouseEvent) {
if (!page.value) return;
const menu: MenuItem[] = [
...($i && $i.id === page.value.userId ? [
{
icon: 'ti ti-code',
text: i18n.ts._pages.viewSource,
action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`),
},
...($i.pinnedPageId === page.value.id ? [{
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => pin(false),
}] : [{
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => pin(true),
}]),
] : []),
...($i && $i.id !== page.value.userId ? [
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
},
...($i.isModerator || $i.isAdmin ? [
{
type: 'divider' as const,
},
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !page.value) return;
os.apiWithDialog('pages/delete', { pageId: page.value.id });
}),
},
] : []),
] : []),
];
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
watch(() => path.value, fetchPage, { immediate: true }); watch(() => path.value, fetchPage, { immediate: true });
const headerActions = computed(() => []); const headerActions = computed(() => []);

View file

@ -96,6 +96,7 @@ const decorationsForPreview = computed(() => {
flipH: flipH.value, flipH: flipH.value,
offsetX: offsetX.value, offsetX: offsetX.value,
offsetY: offsetY.value, offsetY: offsetY.value,
blink: true,
}; };
const decorations = [...$i.avatarDecorations]; const decorations = [...$i.avatarDecorations];
if (props.usingIndex != null) { if (props.usingIndex != null) {

View file

@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, provide, shallowRef, ref } from 'vue'; import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import MkTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
@ -53,15 +53,18 @@ import { deepMerge } from '@/scripts/merge.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import type { BasicTimelineType } from '@/timelines.js';
provide('shouldOmitHeaderTitle', true); provide('shouldOmitHeaderTitle', true);
const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = shallowRef<HTMLElement>(); const rootEl = shallowRef<HTMLElement>();
type TimelinePageSrc = BasicTimelineType | `list:${string}`;
const queue = ref(0); const queue = ref(0);
const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global');
const src = computed<'home' | 'local' | 'social' | 'global' | `list:${string}`>({ const src = computed<TimelinePageSrc>({
get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value),
set: (x) => saveSrc(x), set: (x) => saveSrc(x),
}); });
@ -195,7 +198,7 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
os.popupMenu(items, ev.currentTarget ?? ev.target); os.popupMenu(items, ev.currentTarget ?? ev.target);
} }
function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | `list:${string}`): void { function saveSrc(newSrc: TimelinePageSrc): void {
const out = deepMerge({ src: newSrc }, defaultStore.state.tl); const out = deepMerge({ src: newSrc }, defaultStore.state.tl);
if (newSrc.startsWith('userList:')) { if (newSrc.startsWith('userList:')) {
@ -236,6 +239,19 @@ function closeTutorial(): void {
defaultStore.set('timelineTutorials', before); defaultStore.set('timelineTutorials', before);
} }
function switchTlIfNeeded() {
if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) {
src.value = availableBasicTimelines()[0];
}
}
onMounted(() => {
switchTlIfNeeded();
});
onActivated(() => {
switchTlIfNeeded();
});
const headerActions = computed(() => { const headerActions = computed(() => {
const tmp = [ const tmp = [
{ {

View file

@ -84,7 +84,7 @@ onUpdated(() => {
left: 0; left: 0;
width: 100%; width: 100%;
height: 64px; height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
} }
} }

View file

@ -16,21 +16,57 @@ function containsFocusTrappedElements(el: HTMLElement): boolean {
}); });
} }
function getZIndex(el: HTMLElement): number {
const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10);
if (isNaN(zIndex)) {
return 0;
}
return zIndex;
}
function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null {
let highestZIndexElement: HTMLElement | null = null;
let highestZIndex = -Infinity;
focusTrapElements.forEach((el) => {
const zIndex = getZIndex(el);
if (zIndex > highestZIndex) {
highestZIndex = zIndex;
highestZIndexElement = el;
}
});
return highestZIndexElement == null ? null : {
el: highestZIndexElement,
zIndex: highestZIndex,
};
}
function releaseFocusTrap(el: HTMLElement): void { function releaseFocusTrap(el: HTMLElement): void {
focusTrapElements.delete(el); focusTrapElements.delete(el);
if (el.inert === true) { if (el.inert === true) {
el.inert = false; el.inert = false;
} }
const highestZIndexElement = getHighestZIndexElement();
if (el.parentElement != null && el !== document.body) { if (el.parentElement != null && el !== document.body) {
el.parentElement.childNodes.forEach((siblingNode) => { el.parentElement.childNodes.forEach((siblingNode) => {
const siblingEl = getHTMLElementOrNull(siblingNode); const siblingEl = getHTMLElementOrNull(siblingNode);
if (!siblingEl) return; if (!siblingEl) return;
if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) { if (
siblingEl !== el &&
(
highestZIndexElement == null ||
siblingEl === highestZIndexElement.el ||
siblingEl.contains(highestZIndexElement.el)
)
) {
siblingEl.inert = false; siblingEl.inert = false;
} else if ( } else if (
focusTrapElements.size > 0 && highestZIndexElement != null &&
!containsFocusTrappedElements(siblingEl) && siblingEl !== highestZIndexElement.el &&
!focusTrapElements.has(siblingEl) && !siblingEl.contains(highestZIndexElement.el) &&
!ignoreElements.includes(siblingEl.tagName.toLowerCase()) !ignoreElements.includes(siblingEl.tagName.toLowerCase())
) { ) {
siblingEl.inert = true; siblingEl.inert = true;
@ -45,9 +81,29 @@ function releaseFocusTrap(el: HTMLElement): void {
export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void;
export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; }; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; };
export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void { export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void {
const highestZIndexElement = getHighestZIndexElement();
const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex;
const zIndex = getZIndex(el);
// If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead
// Focus trapping for this element will be done in the release function
if (!parent && zIndex < highestZIndex) {
focusTrapElements.add(el);
if (highestZIndexElement) {
focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls);
}
return {
release: () => {
releaseFocusTrap(el);
},
};
}
if (el.inert === true) { if (el.inert === true) {
el.inert = false; el.inert = false;
} }
if (el.parentElement != null && el !== document.body) { if (el.parentElement != null && el !== document.body) {
el.parentElement.childNodes.forEach((siblingNode) => { el.parentElement.childNodes.forEach((siblingNode) => {
const siblingEl = getHTMLElementOrNull(siblingNode); const siblingEl = getHTMLElementOrNull(siblingNode);

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
export function getAppearNote(note: Misskey.entities.Note) {
return Misskey.note.isPureRenote(note) ? note.renote : note;
}

View file

@ -20,6 +20,7 @@ import { clipsCache, favoritedChannelsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
export async function getNoteClipMenu(props: { export async function getNoteClipMenu(props: {
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -34,14 +35,7 @@ export async function getNoteClipMenu(props: {
} }
} }
const isRenote = ( const appearNote = getAppearNote(props.note);
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note;
const clips = await clipsCache.fetch(); const clips = await clipsCache.fetch();
const menu: MenuItem[] = [...clips.map(clip => ({ const menu: MenuItem[] = [...clips.map(clip => ({
@ -72,6 +66,11 @@ export async function getNoteClipMenu(props: {
}); });
if (props.currentClip?.id === clip.id) props.isDeleted.value = true; if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
} }
} else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') {
os.alert({
type: 'error',
text: i18n.ts.clipNoteLimitExceeded,
});
} else { } else {
os.alert({ os.alert({
type: 'error', type: 'error',
@ -164,14 +163,7 @@ export function getNoteMenu(props: {
isDeleted: Ref<boolean>; isDeleted: Ref<boolean>;
currentClip?: Misskey.entities.Clip; currentClip?: Misskey.entities.Clip;
}) { }) {
const isRenote = ( const appearNote = getAppearNote(props.note);
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note;
const cleanups = [] as (() => void)[]; const cleanups = [] as (() => void)[];
@ -248,6 +240,7 @@ export function getNoteMenu(props: {
} }
async function unclip(): Promise<void> { async function unclip(): Promise<void> {
if (!props.currentClip) return;
os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
props.isDeleted.value = true; props.isDeleted.value = true;
} }
@ -267,8 +260,8 @@ export function getNoteMenu(props: {
function share(): void { function share(): void {
navigator.share({ navigator.share({
title: i18n.tsx.noteOf({ user: appearNote.user.name }), title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }),
text: appearNote.text, text: appearNote.text ?? '',
url: `${url}/notes/${appearNote.id}`, url: `${url}/notes/${appearNote.id}`,
}); });
} }
@ -509,14 +502,7 @@ export function getRenoteMenu(props: {
renoteButton: ShallowRef<HTMLElement | undefined>; renoteButton: ShallowRef<HTMLElement | undefined>;
mock?: boolean; mock?: boolean;
}) { }) {
const isRenote = ( const appearNote = getAppearNote(props.note);
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note;
const channelRenoteItems: MenuItem[] = []; const channelRenoteItems: MenuItem[] = [];
const normalRenoteItems: MenuItem[] = []; const normalRenoteItems: MenuItem[] = [];

View file

@ -137,7 +137,6 @@ export class I18n<T extends ILocale> {
return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>; return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>;
} }
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.tsxCache) { if (this.tsxCache) {
return this.tsxCache; return this.tsxCache;
} }
@ -244,51 +243,3 @@ export class I18n<T extends ILocale> {
return str; return str;
} }
} }
if (import.meta.vitest) {
const { describe, expect, it } = import.meta.vitest;
describe('i18n', () => {
it('t', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.t('foo')).toBe('foo');
expect(i18n.t('bar.baz')).toBe('baz');
expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge');
expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga');
});
it('ts', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.ts.foo).toBe('foo');
expect(i18n.ts.bar.baz).toBe('baz');
});
it('tsx', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge');
expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga');
});
});
}

View file

@ -13,6 +13,7 @@ import { apiUrl } from '@/config.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { alert } from '@/os.js'; import { alert } from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
type Uploading = { type Uploading = {
id: string; id: string;
@ -39,6 +40,15 @@ export function uploadFile(
if (folder && typeof folder === 'object') folder = folder.id; if (folder && typeof folder === 'object') folder = folder.id;
if (file.size > instance.maxFileSize) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
return Promise.reject();
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = uuid(); const id = uuid();

View file

@ -255,11 +255,11 @@ rt {
background: var(--accent); background: var(--accent);
&:not(:disabled):hover { &:not(:disabled):hover {
background: var(--X8); background: hsl(from var(--accent) h s calc(l + 5));
} }
&:not(:disabled):active { &:not(:disabled):active {
background: var(--X9); background: hsl(from var(--accent) h s calc(l - 5));
} }
} }
@ -269,11 +269,11 @@ rt {
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
&:not(:disabled):hover { &:not(:disabled):hover {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
&:not(:disabled):active { &:not(:disabled):active {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
} }

View file

@ -77,22 +77,14 @@
codeBoolean: '#c59eff', codeBoolean: '#c59eff',
deckBg: '#000', deckBg: '#000',
htmlThemeColor: '@bg', htmlThemeColor: '@bg',
X2: ':darken<2<@panel',
X3: 'rgba(255, 255, 255, 0.05)', X3: 'rgba(255, 255, 255, 0.05)',
X4: 'rgba(255, 255, 255, 0.1)', X4: 'rgba(255, 255, 255, 0.1)',
X5: 'rgba(255, 255, 255, 0.05)', X5: 'rgba(255, 255, 255, 0.05)',
X6: 'rgba(255, 255, 255, 0.15)', X6: 'rgba(255, 255, 255, 0.15)',
X7: 'rgba(255, 255, 255, 0.05)', X7: 'rgba(255, 255, 255, 0.05)',
X8: ':lighten<5<@accent',
X9: ':darken<5<@accent',
X10: ':alpha<0.4<@accent',
X11: 'rgba(0, 0, 0, 0.3)', X11: 'rgba(0, 0, 0, 0.3)',
X12: 'rgba(255, 255, 255, 0.1)', X12: 'rgba(255, 255, 255, 0.1)',
X13: 'rgba(255, 255, 255, 0.15)', X13: 'rgba(255, 255, 255, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg',
}, },
codeHighlighter: { codeHighlighter: {

View file

@ -77,22 +77,14 @@
codeBoolean: '#62b70c', codeBoolean: '#62b70c',
deckBg: ':darken<3<@bg', deckBg: ':darken<3<@bg',
htmlThemeColor: '@bg', htmlThemeColor: '@bg',
X2: ':darken<2<@panel',
X3: 'rgba(0, 0, 0, 0.05)', X3: 'rgba(0, 0, 0, 0.05)',
X4: 'rgba(0, 0, 0, 0.1)', X4: 'rgba(0, 0, 0, 0.1)',
X5: 'rgba(0, 0, 0, 0.05)', X5: 'rgba(0, 0, 0, 0.05)',
X6: 'rgba(0, 0, 0, 0.25)', X6: 'rgba(0, 0, 0, 0.25)',
X7: 'rgba(0, 0, 0, 0.05)', X7: 'rgba(0, 0, 0, 0.05)',
X8: ':lighten<5<@accent',
X9: ':darken<5<@accent',
X10: ':alpha<0.4<@accent',
X11: 'rgba(0, 0, 0, 0.1)', X11: 'rgba(0, 0, 0, 0.1)',
X12: 'rgba(0, 0, 0, 0.1)', X12: 'rgba(0, 0, 0, 0.1)',
X13: 'rgba(0, 0, 0, 0.15)', X13: 'rgba(0, 0, 0, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg',
}, },
codeHighlighter: { codeHighlighter: {

View file

@ -57,20 +57,13 @@
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelHeaderDivider: 'rgba(0, 0, 0, 0)',
scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
X2: ':darken<2<@panel',
X3: 'rgba(255, 255, 255, 0.05)', X3: 'rgba(255, 255, 255, 0.05)',
X4: 'rgba(255, 255, 255, 0.1)', X4: 'rgba(255, 255, 255, 0.1)',
X5: 'rgba(255, 255, 255, 0.05)', X5: 'rgba(255, 255, 255, 0.05)',
X6: 'rgba(255, 255, 255, 0.15)', X6: 'rgba(255, 255, 255, 0.15)',
X7: 'rgba(255, 255, 255, 0.05)', X7: 'rgba(255, 255, 255, 0.05)',
X8: ':lighten<5<@accent',
X9: ':darken<5<@accent',
X10: ':alpha<0.4<@accent',
X11: 'rgba(0, 0, 0, 0.3)', X11: 'rgba(0, 0, 0, 0.3)',
X12: 'rgba(255, 255, 255, 0.1)', X12: 'rgba(255, 255, 255, 0.1)',
X13: 'rgba(255, 255, 255, 0.15)', X13: 'rgba(255, 255, 255, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel',
}, },
} }

View file

@ -3,14 +3,11 @@
base: 'dark', base: 'dark',
name: 'Mi U0 Dark', name: 'Mi U0 Dark',
props: { props: {
X2: ':darken<2<@panel',
X3: 'rgba(255, 255, 255, 0.05)', X3: 'rgba(255, 255, 255, 0.05)',
X4: 'rgba(255, 255, 255, 0.1)', X4: 'rgba(255, 255, 255, 0.1)',
X5: 'rgba(255, 255, 255, 0.05)', X5: 'rgba(255, 255, 255, 0.05)',
X6: 'rgba(255, 255, 255, 0.15)', X6: 'rgba(255, 255, 255, 0.15)',
X7: 'rgba(255, 255, 255, 0.05)', X7: 'rgba(255, 255, 255, 0.05)',
X8: ':lighten<5<@accent',
X9: ':darken<5<@accent',
bg: '#172426', bg: '#172426',
fg: '#dadada', fg: '#dadada',
X10: ':alpha<0.4<@accent', X10: ':alpha<0.4<@accent',

View file

@ -3,14 +3,11 @@
base: 'light', base: 'light',
name: 'Mi U0 Light', name: 'Mi U0 Light',
props: { props: {
X2: ':darken<2<@panel',
X3: 'rgba(255, 255, 255, 0.05)', X3: 'rgba(255, 255, 255, 0.05)',
X4: 'rgba(255, 255, 255, 0.1)', X4: 'rgba(255, 255, 255, 0.1)',
X5: 'rgba(255, 255, 255, 0.05)', X5: 'rgba(255, 255, 255, 0.05)',
X6: 'rgba(255, 255, 255, 0.15)', X6: 'rgba(255, 255, 255, 0.15)',
X7: 'rgba(255, 255, 255, 0.05)', X7: 'rgba(255, 255, 255, 0.05)',
X8: ':lighten<5<@accent',
X9: ':darken<5<@accent',
bg: '#e7e7eb', bg: '#e7e7eb',
fg: '#5f5f5f', fg: '#5f5f5f',
X10: ':alpha<0.4<@accent', X10: ':alpha<0.4<@accent',

View file

@ -60,21 +60,13 @@
fgTransparentWeak: ':alpha<0.75<@fg', fgTransparentWeak: ':alpha<0.75<@fg',
panelHeaderDivider: '@divider', panelHeaderDivider: '@divider',
scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)',
X2: ':darken<2<@panel',
X3: 'rgba(0, 0, 0, 0.05)', X3: 'rgba(0, 0, 0, 0.05)',
X4: 'rgba(0, 0, 0, 0.1)', X4: 'rgba(0, 0, 0, 0.1)',
X5: 'rgba(0, 0, 0, 0.05)', X5: 'rgba(0, 0, 0, 0.05)',
X6: 'rgba(0, 0, 0, 0.25)', X6: 'rgba(0, 0, 0, 0.25)',
X7: 'rgba(0, 0, 0, 0.05)', X7: 'rgba(0, 0, 0, 0.05)',
X8: ':lighten<5<@accent',
X9: ':darken<5<@accent',
X10: ':alpha<0.4<@accent',
X11: 'rgba(0, 0, 0, 0.1)', X11: 'rgba(0, 0, 0, 0.1)',
X12: 'rgba(0, 0, 0, 0.1)', X12: 'rgba(0, 0, 0, 0.1)',
X13: 'rgba(0, 0, 0, 0.15)', X13: 'rgba(0, 0, 0, 0.15)',
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg',
}, },
} }

View file

@ -82,6 +82,8 @@ function more() {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
--nav-bg-transparent: color-mix(in srgb, var(--navBg), transparent 50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -91,7 +93,7 @@ function more() {
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 20px 0; padding: 20px 0;
background: var(--X14); background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@ -125,7 +127,7 @@ function more() {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding: 20px 0; padding: 20px 0;
background: var(--X14); background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }

View file

@ -111,6 +111,7 @@ function more(ev: MouseEvent) {
.root { .root {
--nav-width: 250px; --nav-width: 250px;
--nav-icon-only-width: 80px; --nav-icon-only-width: 80px;
--nav-bg-transparent: color-mix(in srgb, var(--navBg), transparent 50%);
flex: 0 0 var(--nav-width); flex: 0 0 var(--nav-width);
width: var(--nav-width); width: var(--nav-width);
@ -144,7 +145,7 @@ function more(ev: MouseEvent) {
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 20px 0; padding: 20px 0;
background: var(--X14); background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@ -187,7 +188,7 @@ function more(ev: MouseEvent) {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding-top: 20px; padding-top: 20px;
background: var(--X14); background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@ -378,7 +379,7 @@ function more(ev: MouseEvent) {
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 20px 0; padding: 20px 0;
background: var(--X14); background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }
@ -408,7 +409,7 @@ function more(ev: MouseEvent) {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding-top: 20px; padding-top: 20px;
background: var(--X14); background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--blur, blur(8px)); -webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px)); backdrop-filter: var(--blur, blur(8px));
} }

View file

@ -455,7 +455,7 @@ body {
} }
&:active { &:active {
background: var(--X2); background: hsl(from var(--panel) h s calc(l - 2));
} }
} }
@ -465,11 +465,11 @@ body {
color: var(--fgOnAccent); color: var(--fgOnAccent);
&:hover { &:hover {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
&:active { &:active {
background: linear-gradient(90deg, var(--X8), var(--X8)); background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
} }
} }

Some files were not shown because too many files have changed in this diff Show more