Compare commits

...

62 Commits

Author SHA1 Message Date
tamaina
d8f65ca426 fix 2023-05-27 12:27:52 +00:00
tamaina
16b50fc6a9 fix(frontend): デッキモードかつ直接/以外を表示したときにデッキが表示されない問題を修正
Fix #10905
2023-05-27 12:01:00 +00:00
syuilo
fb54c58a66 🎨 2023-05-27 12:09:19 +09:00
syuilo
3a924f3dc6 refactor 2023-05-27 11:44:04 +09:00
syuilo
11d22c7b73 refactor 2023-05-27 11:38:08 +09:00
syuilo
a879607479 refactor 2023-05-27 11:35:26 +09:00
Chocolate Pie
98aef974df enhance: ハッシュタグのノート一覧ページから、そのハッシュタグで投稿するボタンを追加、お知らせの画像URLを空にできない問題を修正 (#10878)
* fix: お知らせの画像URLを空にできない問題を修正 (misskey-dev/misskey#10657)

* ハッシュタグのノート一覧ページからノートできるように(misskey-dev/misskey#10854)

* fix: 色々直した

* location.reloadを使わないように

* CHANGELOGを編集

* tweak

* Update tag.vue

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-05-26 14:47:31 +09:00
NoriDev
cf46816687 feat: アカウント初期設定ウィザードに戻るボタンと、後で進むボタンを追加 (#10893)
* enhance(frontend): アカウント初期設定ウィザードに戻るボタンを追加

* enhance(frontend): アカウント初期設定ウィザードにあとでボタンを追加

* tweak

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-05-26 14:40:44 +09:00
tamaina
eee1e74174 shareページに"Misskeyへ"ボタンを設置
Resolve #10898
2023-05-26 05:18:01 +00:00
tamaina
8050f89d7e Revert "fix(client): /shareをsubBootPathsから外す"
This reverts commit 406e5d297b.
2023-05-26 05:06:52 +00:00
tamaina
406e5d297b fix(client): /shareをsubBootPathsから外す
Resolve #10898
2023-05-26 04:34:34 +00:00
syuilo
10634b3615 refactor 2023-05-26 13:32:42 +09:00
syuilo
fd03e2e1a7 🎨 2023-05-26 13:30:26 +09:00
syuilo
6cc86272f3 🎨 2023-05-26 13:24:31 +09:00
syuilo
06b1250d47 🎨 2023-05-26 11:31:39 +09:00
syuilo
31a7350a10 🎨 2023-05-26 10:48:49 +09:00
tamaina
4129ac157a package.jsonの並び替えを修正 2023-05-25 14:50:14 +00:00
syuilo
30cb791e93 enhance(frontend): フォロー/フォロー解除したときに自動でTLをリロードするのをやめるように
- 不便に感じる場合が多いように思う
- 将来的にTLがpush型になったら無意味になる
2023-05-25 08:17:09 +09:00
syuilo
1c57983bfd refactor 2023-05-24 17:50:15 +09:00
syuilo
bdf08c8a54 refactor 2023-05-24 17:33:31 +09:00
syuilo
0513ff8b4e refactor 2023-05-24 17:29:58 +09:00
Acid Chicken (硫酸鶏)
62fe3bfb54 refactor(#7598): add i18n dynamic typings (#10882)
* refactor: add i18n dynamic typings

* chore: tweak
2023-05-24 17:12:38 +09:00
syuilo
38a1d6693a 🎨 2023-05-24 14:43:53 +09:00
syuilo
d2eec3a9e4 refactor 2023-05-24 14:34:46 +09:00
syuilo
1de774fa3d update deps 2023-05-24 10:16:42 +09:00
syuilo
ed902658a9 refactor 2023-05-24 09:59:30 +09:00
Caipira
acdcd7c623 enhance(frontend): improve signup complete ui (#10876)
* enhance(frontend): improve signup complete ui

* relocation

* tweak

* Update _boot_.ts

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-05-24 09:43:38 +09:00
syuilo
b0344e07c4 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-05-21 20:49:40 +09:00
Acid Chicken (硫酸鶏)
9a6ce1e867 ci: fix head user 2023-05-20 18:52:08 +00:00
Acid Chicken (硫酸鶏)
22a6bd6b22 ci: fix branch name 2023-05-20 14:03:05 +00:00
syuilo
38e6f3f776 🎨 2023-05-20 12:32:51 +09:00
syuilo
ca75afe065 13.13.0-beta.3 2023-05-20 10:35:56 +09:00
Acid Chicken (硫酸鶏)
915ed39715 feat: support summary_large_image (#10862)
* feat: use summary_large_image

* chore: add video

* chore: ignore sensitive image on note
2023-05-20 10:35:23 +09:00
Acid Chicken (硫酸鶏)
81fd94e635 chore: update slacc (#10867)
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-05-20 10:34:31 +09:00
syuilo
05507a4bea enhance(frontend): Deck UIを使用している場合、/以外にアクセスした際にZen UIで表示するように
Resolve #10871
2023-05-20 10:31:53 +09:00
syuilo
d177f97928 refactor 2023-05-20 10:12:18 +09:00
syuilo
30cb03a40d Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-05-20 10:05:39 +09:00
syuilo
c685989e67 refactor 2023-05-20 10:05:35 +09:00
Acid Chicken (硫酸鶏)
ee3f408c7d feat: impl IdlingRenderScheduler (#10547)
* feat: impl IdleRender

* test: pin time on Chromatic

* test: pin time on Chromatic

* fix: typo

* style: rename

* style: rename

* chore: back to setTimeout

* style: linebreak

* refactor: remove unused budget option

* refactor: use raw unix time

* fix: conflict error

* fix: floor

* fix: subtract

* Revert "fix: subtract"

This reverts commit 2ef4afaafc.

* Revert "fix: floor"

This reverts commit bef8ecdf45.

* Revert "refactor: use raw unix time"

This reverts commit 5199e13cb2.
2023-05-20 03:38:07 +09:00
syuilo
1eb35dd5bc delete /docs 2023-05-19 20:59:21 +09:00
Nanashia
15db0b8812 test(backend): Add tests for antennas (#10868) 2023-05-19 20:53:20 +09:00
syuilo
1b78c6a309 refactor 2023-05-19 20:52:15 +09:00
syuilo
c713af8e23 refactor 2023-05-19 18:16:26 +09:00
nenohi
bd6666173a fix:ロールタイムライン無効に関する修正 (#10843)
* 修正

* note visibility change

* public投稿のみ

* update changelog

* RN非表示機能がうごかないところだった
2023-05-19 17:12:22 +09:00
tamaina
02715f5d14 update pnpm to 8.5.1 2023-05-19 07:36:47 +00:00
syuilo
acd5e0b8f6 update deps 2023-05-19 16:34:56 +09:00
syuilo
be2142bb13 refactor 2023-05-19 16:30:39 +09:00
syuilo
4a703d7cf6 refactor 2023-05-19 16:25:48 +09:00
syuilo
95470a40a7 refactor 2023-05-19 16:20:53 +09:00
syuilo
56d4658b36 refactor(frontend): use css module 2023-05-19 14:08:51 +09:00
syuilo
f68008b002 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-05-19 13:58:33 +09:00
syuilo
6a5ef5b6f2 refactor 2023-05-19 13:58:09 +09:00
syuilo
95b9284e79 🎨 2023-05-19 13:35:46 +09:00
まっぴー
8317772436 fix:Firefoxでサインアップする時、メールアドレスがユーザー名として認識される#10523 (#10869)
* fix:Firefoxでサインアップする時、メールアドレスがユーザー名として認識される

* Update CHANGELOG.md

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

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

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

* feat: インポートに対応

* wip

* wip

* CHANGELOGを編集

* add note

* refactor

---------

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

* fix tsconfig

* fetch image??

* Revert "fetch image??"

This reverts commit 0925c28d5a.

* wip

* Revert "wip"

This reverts commit be97c6cb88.

* loading="eager"

* loading="eager" 2

* error

* wip

* wip

* wip

* wip

* clean up

* fix

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

* clean up

* use buraha

* wip

* smaller width, height

* update buraha

* clean up

* fix

* Update MkMediaImage.vue

* Update MkImgWithBlurhash.vue

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

This reverts commit 41e9aa6f9b.

* Update MkMediaList.vue

* Update MkMediaList.vue

* Update MkMediaList.vue

* Update CHANGELOG.md

* wait for decode

* fix

* ?

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

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

This reverts commit 9448e64228.

* container-name

* Revert "container-name"

This reverts commit 94385d3221.

* width: 100%;

* improve performance

* refactor

* wip

* WIP

* wip

* Revert "wip"

This reverts commit 36e3b75cab.

* Revert "WIP"

This reverts commit 05b729ef91.

* Revert "wip"

This reverts commit 0801e79361.

* #10860

* wip

* no worker

* Revert "no worker"

This reverts commit a9c49e4fb4.

* ✌️

* workerNumber固定は不要

---------

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

View File

@@ -86,7 +86,11 @@ jobs:
if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
fi
pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static $(echo "$CHROMATIC_PARAMETER")
BRANCH="${{ github.event.pull_request.head.user.login }}:${{ github.event.pull_request.head.ref }}"
if [ "$BRANCH" = "misskey-dev:${{ github.event.pull_request.head.ref }}" ]; then
BRANCH="${{ github.event.pull_request.head.ref }}"
fi
pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name $BRANCH $(echo "$CHROMATIC_PARAMETER")
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- name: Notify that Chromatic detects changes

View File

@@ -17,13 +17,28 @@
### General
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
- カスタム絵文字ごとに連合するかどうか設定できるように
- カスタム絵文字ごとにセンシティブフラグを設定できるように
- センシティブなカスタム絵文字のリアクションを受け入れない設定が可能に
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります
- リストを公開できるようになりました
### Client
- リアクションの取り消し/変更時に確認ダイアログを出すように
- 開発者モードを追加
- AiScriptを0.13.3に更新
- Deck UIを使用している場合、`/`以外にアクセスした際にZen UIで表示するように
- メインカラムを設置していない場合の問題を解決
- ハッシュタグのノート一覧ページから、そのハッシュタグで投稿するボタンを追加
- アカウント初期設定ウィザードに戻るボタンを追加
- アカウントの初期設定ウィザードにあとでボタンを追加
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
- Fix: Safari、Firefoxでの新規登録時、パスワードマネージャーにメールアドレスが登録されていた挙動を修正
- fix:ロールタイムラインが無効でも投稿が流れてしまう問題の修正
- fix:ロールタイムラインにて全ての投稿が流れてしまう問題の修正
### Server
- Fix: お知らせの画像URLを空にできない問題を修正
## 13.12.2
@@ -100,6 +115,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
* 画像が全て隠れた状態で表示されるようになります
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
- モデレーターはートに添付された画像上から直接NSFW設定できるように
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
- 新しい実績を追加
- AiScriptを0.13.2に更新

View File

@@ -169,20 +169,25 @@ describe('After user signed in', () => {
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
// TODO: アイコン設定テスト
cy.get('[data-cy-user-setup-back]').click();
cy.get('[data-cy-user-setup-continue]').click();
// プライバシー設定
cy.get('[data-cy-user-setup-back]').click();
cy.get('[data-cy-user-setup-continue]').click();
// フォローはスキップ
cy.get('[data-cy-user-setup-back]').click();
cy.get('[data-cy-user-setup-continue]').click();
// プッシュ通知設定はスキップ
cy.get('[data-cy-user-setup-back]').click();
cy.get('[data-cy-user-setup-continue]').click();
cy.get('[data-cy-user-setup-back]').click();
cy.get('[data-cy-user-setup-continue]').click();
});
});

View File

@@ -1,25 +0,0 @@
DONATORS
========
The list of people who have sent donation for Misskey.
(In random order, honorific titles are omitted.)
* らふぁ
* 俺様
* なぎうり
* スルメ https://surume.tk/
*
* 音船 https://otofune.me/
* aqz https://misskey.xyz/aqz
* kotodu "虚無創作中"
* Maya Minatsuki
* Knzk https://knzk.me/@Knzk
* ねじりわさび https://knzk.me/@y
* NCLS https://knzk.me/@imncls]
* こじま @skoji@sandbox.skoji.jp
:heart: Thanks for donating, guys!
---
If your name is missing, please contact us!

72
locales/generateDTS.js Normal file
View File

@@ -0,0 +1,72 @@
const fs = require('fs');
const yaml = require('js-yaml');
const ts = require('typescript');
function createMembers(record) {
return Object.entries(record)
.map(([k, v]) => ts.factory.createPropertySignature(
undefined,
ts.factory.createStringLiteral(k),
undefined,
typeof v === 'string'
? ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)
: ts.factory.createTypeLiteralNode(createMembers(v)),
));
}
module.exports = function generateDTS() {
const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
const members = createMembers(locale);
const elements = [
ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('Locale'),
undefined,
undefined,
members,
),
ts.factory.createVariableStatement(
[ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)],
ts.factory.createVariableDeclarationList(
[ts.factory.createVariableDeclaration(
ts.factory.createIdentifier('locales'),
undefined,
ts.factory.createTypeLiteralNode([ts.factory.createIndexSignature(
undefined,
[ts.factory.createParameterDeclaration(
undefined,
undefined,
ts.factory.createIdentifier('lang'),
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
undefined,
)],
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Locale'),
undefined,
),
)]),
undefined,
)],
ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags,
),
),
ts.factory.createExportAssignment(
undefined,
true,
ts.factory.createIdentifier('locales'),
),
];
const printed = ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,
}).printList(
ts.ListFormat.MultiLine,
ts.factory.createNodeArray(elements),
ts.createSourceFile('index.d.ts', '', ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS),
);
fs.writeFileSync(`${__dirname}/index.d.ts`, `/* eslint-disable */
// This file is generated by locales/generateDTS.js
// Do not edit this file directly.
${printed}`, 'utf-8');
}

2149
locales/index.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@@ -792,6 +792,7 @@ noMaintainerInformationWarning: "管理者情報が設定されていません
noBotProtectionWarning: "Botプロテクションが設定されていません。"
configure: "設定する"
postToGallery: "ギャラリーへ投稿"
postToHashtag: "このハッシュタグで投稿"
gallery: "ギャラリー"
recentPosts: "最近の投稿"
popularPosts: "人気の投稿"
@@ -990,7 +991,9 @@ postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後から変更できません。"
reactionAcceptance: "リアクションの受け入れ"
likeOnly: "いいねのみ"
likeOnlyForRemote: "リモートからはいいねのみ"
likeOnlyForRemote: "全て (リモートはいいねのみ)"
nonSensitiveOnly: "非センシティブのみ"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)"
rolesAssignedToMe: "自分に割り当てられたロール"
resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード"
@@ -1053,6 +1056,10 @@ update: "更新"
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールである必要があります。"
cancelReactionConfirm: "リアクションを取り消しますか?"
changeReactionConfirm: "リアクションを変更しますか?"
later: "あとで"
goToMisskey: "Misskeyへ"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"
@@ -1068,6 +1075,7 @@ _initialAccountSetting:
haveFun: "{name}をお楽しみください!"
ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。"
skipAreYouSure: "初期設定をスキップしますか?"
laterAreYouSure: "初期設定をあとでやり直しますか?"
_serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"

View File

@@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "13.13.0-beta.1",
"version": "13.13.0-beta.3",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@8.3.1",
"packageManager": "pnpm@8.5.1",
"workspaces": [
"packages/frontend",
"packages/backend",
@@ -59,7 +59,7 @@
"@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5",
"cross-env": "7.0.3",
"cypress": "12.12.0",
"cypress": "12.13.0",
"eslint": "8.40.0",
"start-server-and-test": "2.0.0"
},

View File

@@ -0,0 +1,13 @@
export class UserList1683847157541 {
name = 'UserList1683847157541'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list" ADD "isPublic" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_48a00f08598662b9ca540521eb" ON "user_list" ("isPublic") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_48a00f08598662b9ca540521eb"`);
await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "isPublic"`);
}
}

View File

@@ -0,0 +1,19 @@
export class UserListFavorites1683869758873 {
name = 'UserListFavorites1683869758873'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "user_list_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_c0974b21e18502a4c8178e09fe6" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_016f613dc4feb807e03e3e7da9" ON "user_list_favorite" ("userId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d6765a8c2a4c17c33f9d7f948b" ON "user_list_favorite" ("userId", "userListId") `);
await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_016f613dc4feb807e03e3e7da92" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2"`);
await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_016f613dc4feb807e03e3e7da92"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d6765a8c2a4c17c33f9d7f948b"`);
await queryRunner.query(`DROP INDEX "public"."IDX_016f613dc4feb807e03e3e7da9"`);
await queryRunner.query(`DROP TABLE "user_list_favorite"`);
}
}

View File

@@ -35,25 +35,26 @@
"@swc/core-win32-x64-msvc": "1.3.56",
"@tensorflow/tfjs": "4.4.0",
"@tensorflow/tfjs-node": "4.4.0",
"slacc-android-arm-eabi": "0.0.7",
"slacc-android-arm64": "0.0.7",
"slacc-darwin-arm64": "0.0.7",
"slacc-darwin-universal": "0.0.7",
"slacc-darwin-x64": "0.0.7",
"slacc-linux-arm-gnueabihf": "0.0.7",
"slacc-linux-arm64-gnu": "0.0.7",
"slacc-linux-arm64-musl": "0.0.7",
"slacc-linux-x64-gnu": "0.0.7",
"slacc-win32-arm64-msvc": "0.0.7",
"slacc-win32-x64-msvc": "0.0.7"
"slacc-android-arm-eabi": "0.0.9",
"slacc-android-arm64": "0.0.9",
"slacc-darwin-arm64": "0.0.9",
"slacc-darwin-universal": "0.0.9",
"slacc-darwin-x64": "0.0.9",
"slacc-freebsd-x64": "0.0.9",
"slacc-linux-arm-gnueabihf": "0.0.9",
"slacc-linux-arm64-gnu": "0.0.9",
"slacc-linux-arm64-musl": "0.0.9",
"slacc-linux-x64-gnu": "0.0.9",
"slacc-win32-arm64-msvc": "0.0.9",
"slacc-win32-x64-msvc": "0.0.9"
},
"dependencies": {
"@aws-sdk/client-s3": "3.321.1",
"@aws-sdk/lib-storage": "3.321.1",
"@aws-sdk/node-http-handler": "3.321.1",
"@bull-board/api": "5.1.2",
"@bull-board/fastify": "5.1.2",
"@bull-board/ui": "5.1.2",
"@bull-board/api": "5.2.0",
"@bull-board/fastify": "5.2.0",
"@bull-board/ui": "5.2.0",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.1.0",
"@fastify/cookie": "8.3.0",
@@ -62,13 +63,13 @@
"@fastify/multipart": "7.6.0",
"@fastify/static": "6.10.1",
"@fastify/view": "7.4.1",
"@nestjs/common": "9.4.0",
"@nestjs/core": "9.4.0",
"@nestjs/testing": "9.4.0",
"@nestjs/common": "9.4.2",
"@nestjs/core": "9.4.2",
"@nestjs/testing": "9.4.2",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.0.2",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.56",
"@swc/core": "1.3.59",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
@@ -93,7 +94,7 @@
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "12.6.0",
"happy-dom": "9.16.0",
"happy-dom": "9.19.2",
"hpagent": "1.2.0",
"ioredis": "5.3.2",
"ip-cidr": "3.1.0",
@@ -102,8 +103,8 @@
"jsdom": "21.1.1",
"json5": "2.2.3",
"jsonld": "8.1.1",
"meilisearch": "0.32.3",
"jsrsasign": "10.8.6",
"meilisearch": "0.32.4",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
@@ -116,7 +117,7 @@
"os-utils": "0.0.14",
"otpauth": "9.1.2",
"parse5": "7.1.2",
"pg": "8.10.0",
"pg": "8.11.0",
"private-ip": "3.0.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@@ -136,10 +137,10 @@
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"seedrandom": "3.0.5",
"semver": "7.5.0",
"semver": "7.5.1",
"sharp": "0.32.1",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.7",
"slacc": "0.0.9",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
@@ -178,11 +179,11 @@
"@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.8",
"@types/mime-types": "2.1.1",
"@types/node": "20.1.3",
"@types/node": "20.2.3",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.7",
"@types/nodemailer": "6.4.8",
"@types/oauth": "0.9.1",
"@types/pg": "8.6.6",
"@types/pg": "8.10.1",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0",
@@ -196,7 +197,7 @@
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.5",
"@types/unzipper": "0.10.6",
"@types/uuid": "9.0.1",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",

View File

@@ -4,7 +4,7 @@ import * as Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { MeiliSearch } from 'meilisearch';
import { DI } from './di-symbols.js';
import { loadConfig } from './config.js';
import { Config, loadConfig } from './config.js';
import { createPostgresDataSource } from './postgres.js';
import { RepositoryModule } from './models/RepositoryModule.js';
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
@@ -25,7 +25,7 @@ const $db: Provider = {
const $meilisearch: Provider = {
provide: DI.meilisearch,
useFactory: (config) => {
useFactory: (config: Config) => {
if (config.meilisearch) {
return new MeiliSearch({
host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`,
@@ -40,7 +40,7 @@ const $meilisearch: Provider = {
const $redis: Provider = {
provide: DI.redis,
useFactory: (config) => {
useFactory: (config: Config) => {
return new Redis.Redis({
port: config.redis.port,
host: config.redis.host,
@@ -55,7 +55,7 @@ const $redis: Provider = {
const $redisForPub: Provider = {
provide: DI.redisForPub,
useFactory: (config) => {
useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
@@ -71,7 +71,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = {
provide: DI.redisForSub,
useFactory: (config) => {
useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,

View File

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

View File

@@ -306,6 +306,14 @@ export class RoleService implements OnApplicationShutdown {
return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
}
@bindThis
public async isExplorable(role: { id: Role['id']} | null): Promise<boolean> {
if (role == null) return false;
const check = await this.rolesRepository.findOneBy({ id: role.id });
if (check == null) return false;
return check.isExplorable;
}
@bindThis
public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
import { UserList } from './UserList.js';
@Entity()
@Index(['userId', 'userListId'], { unique: true })
export class UserListFavorite {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index()
@Column(id())
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Column(id())
public userListId: UserList['id'];
@ManyToOne(type => UserList, {
onDelete: 'CASCADE',
})
@JoinColumn()
public userList: UserList | null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export const paramDef = {
id: { type: 'string', format: 'misskey:id' },
title: { type: 'string', minLength: 1 },
text: { type: 'string', minLength: 1 },
imageUrl: { type: 'string', nullable: true, minLength: 1 },
imageUrl: { type: 'string', nullable: true, minLength: 0 },
},
required: ['id', 'title', 'text', 'imageUrl'],
} as const;
@@ -46,7 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
updatedAt: new Date(),
title: ps.title,
text: ps.text,
imageUrl: ps.imageUrl,
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
imageUrl: ps.imageUrl || null,
});
});
}

View File

@@ -99,7 +99,7 @@ export const paramDef = {
} },
cw: { type: 'string', nullable: true, maxLength: 100 },
localOnly: { type: 'boolean', default: false },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },

View File

@@ -93,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.andWhere('(note.visibility = \'public\')')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')

View File

@@ -0,0 +1,148 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import type { UserList } from '@/models/entities/UserList.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { RoleService } from '@/core/RoleService.js';
import { UserListService } from '@/core/UserListService.js';
export const meta = {
requireCredential: true,
prohibitMoved: true,
res: {
type: 'object',
optional: false, nullable: false,
ref: 'UserList',
},
errors: {
tooManyUserLists: {
message: 'You cannot create user list any more.',
code: 'TOO_MANY_USERLISTS',
id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f',
},
noSuchList: {
message: 'No such list.',
code: 'NO_SUCH_LIST',
id: '9292f798-6175-4f7d-93f4-b6742279667d',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f',
},
alreadyAdded: {
message: 'That user has already been added to that list.',
code: 'ALREADY_ADDED',
id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615',
},
youHaveBeenBlocked: {
message: 'You cannot push this user because you have been blocked by this user.',
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'a2497f2a-2389-439c-8626-5298540530f4',
},
tooManyUsers: {
message: 'You can not push users any more.',
code: 'TOO_MANY_USERS',
id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
listId: { type: 'string', format: 'misskey:id' },
},
required: ['name', 'listId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
private userListService: UserListService,
private userListEntityService: UserListEntityService,
private idService: IdService,
private getterService: GetterService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const list = await this.userListsRepository.findOneBy({
id: ps.listId,
isPublic: true,
});
if (list === null) throw new ApiError(meta.errors.noSuchList);
const currentCount = await this.userListsRepository.countBy({
userId: me.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) {
throw new ApiError(meta.errors.tooManyUserLists);
}
const userList = await this.userListsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: me.id,
name: ps.name,
} as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
const users = (await this.userListJoiningsRepository.findBy({
userListId: ps.listId,
})).map(x => x.userId);
for (const user of users) {
const currentUser = await this.getterService.getUser(user).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
if (currentUser.id !== me.id) {
const block = await this.blockingsRepository.findOneBy({
blockerId: currentUser.id,
blockeeId: me.id,
});
if (block) {
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
const exist = await this.userListJoiningsRepository.findOneBy({
userListId: userList.id,
userId: currentUser.id,
});
if (exist) {
throw new ApiError(meta.errors.alreadyAdded);
}
try {
await this.userListService.push(currentUser, userList, me);
} catch (err) {
if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers);
}
throw err;
}
}
return await this.userListEntityService.pack(userList);
});
}
}

View File

@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
errors: {
noSuchList: {
message: 'No such user list.',
code: 'NO_SUCH_USER_LIST',
id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe',
},
alreadyFavorited: {
message: 'The list has already been favorited.',
code: 'ALREADY_FAVORITED',
id: '6425bba0-985b-461e-af1b-518070e72081',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor (
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListFavoritesRepository)
private userListFavoritesRepository: UserListFavoritesRepository,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
isPublic: true,
});
if (userList === null) {
throw new ApiError(meta.errors.noSuchList);
}
const exist = await this.userListFavoritesRepository.findOneBy({
userId: me.id,
userListId: ps.listId,
});
if (exist !== null) {
throw new ApiError(meta.errors.alreadyFavorited);
}
await this.userListFavoritesRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
userId: me.id,
userListId: ps.listId,
});
});
}
}

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
errors: {
noSuchList: {
message: 'No such user list.',
code: 'NO_SUCH_USER_LIST',
id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b',
},
notFavorited: {
message: 'You have not favorited the list.',
code: 'ALREADY_FAVORITED',
id: '835c4b27-463d-4cfa-969b-a9058678d465',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
},
required: ['listId'],
} as const;
@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor (
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.userListFavoritesRepository)
private userListFavoritesRepository: UserListFavoritesRepository,
) {
super(meta, paramDef, async (ps, me) => {
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
isPublic: true,
});
if (userList === null) {
throw new ApiError(meta.errors.noSuchList);
}
const exist = await this.userListFavoritesRepository.findOneBy({
userListId: ps.listId,
userId: me.id,
});
if (exist === null) {
throw new ApiError(meta.errors.notFavorited);
}
await this.userListFavoritesRepository.delete({ id: exist.id });
});
}
}

View File

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

View File

@@ -5,15 +5,17 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import Channel from '../channel.js';
import { StreamMessages } from '../types.js';
import { RoleService } from '@/core/RoleService.js';
class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline';
public static shouldShare = false;
public static requireCredential = false;
private roleId: string;
constructor(
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
id: string,
connection: Channel['connection'],
@@ -34,6 +36,11 @@ class RoleTimelineChannel extends Channel {
if (data.type === 'note') {
const note = data.body;
if (!(await this.roleservice.isExplorable({ id: this.roleId }))) {
return;
}
if (note.visibility !== 'public') return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
@@ -61,6 +68,7 @@ export class RoleTimelineChannelService {
constructor(
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
) {
}
@@ -68,6 +76,7 @@ export class RoleTimelineChannelService {
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
return new RoleTimelineChannel(
this.noteEntityService,
this.roleservice,
id,
connection,
);

View File

@@ -160,37 +160,41 @@
<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>
</svg>
<h1>An error has occurred!</h1>
<button class="button-big" onclick="location.reload();">
<span class="button-label-big">Refresh</span>
<h1>Failed to load<br>読み込みに失敗しました</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">Reload / リロード</span>
</button>
<p class="dont-worry">Don't worry, it's (probably) not your fault.</p>
<p>If the problem persists after refreshing, please contact your instance's administrator.<br>You may also try the following options:</p>
<p>Update your os and browser.</p>
<p>Disable an adblocker.</p>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">Clear preferences and cache</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">Start the simple client</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">Start the repair tool</span>
</button>
</a>
<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>Disable an adblocker / アドブロッカーを無効にする</p>
<details style="color: #86b300;">
<summary>Other options / その他のオプション</summary>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">Clear preferences and cache</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">Start the simple client</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">Start the repair tool</span>
</button>
</a>
</details>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.id = 'errorInfo';
detailsElement.innerHTML = `
<br>
<summary>
@@ -247,7 +251,7 @@
.button-label-big {
color: #222;
font-weight: bold;
font-size: 20px;
font-size: 1.2em;
padding: 12px;
}
@@ -267,11 +271,6 @@
font-size: 16px;
}
.dont-worry,
#msg {
font-size: 18px;
}
.icon-warning {
color: #dec340;
height: 4rem;
@@ -279,14 +278,15 @@
}
h1 {
font-size: 32px;
font-size: 1.5em;
margin: 1em;
}
code {
font-family: Fira, FiraCode, monospace;
}
details {
#errorInfo {
background: #333;
margin-bottom: 2rem;
padding: 0.5rem 1rem;
@@ -296,16 +296,16 @@
margin: auto;
}
summary {
#errorInfo summary {
cursor: pointer;
}
summary > * {
#errorInfo summary > * {
display: inline;
}
@media screen and (max-width: 500px) {
details {
#errorInfo {
width: 50%;
}
`)

View File

@@ -25,7 +25,6 @@ html
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
meta(property='twitter:card' content='summary')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='icon' href= icon || '/favicon.ico')
@@ -59,6 +58,7 @@ html
meta(property='og:title' content= title || 'Misskey')
meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
meta(property='og:image' content= img)
meta(property='twitter:card' content='summary')
style
include ../style.css

View File

@@ -16,3 +16,4 @@ block og
meta(property='og:description' content= channel.description)
meta(property='og:url' content= url)
meta(property='og:image' content= channel.bannerUrl)
meta(property='twitter:card' content='summary')

View File

@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= clip.description)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle

View File

@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= flash.summary)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle

View File

@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= post.description)
meta(property='og:url' content= url)
meta(property='og:image' content= post.files[0].thumbnailUrl)
meta(property='twitter:card' content='summary_large_image')
block meta
if user.host || profile.noCrawle

View File

@@ -5,6 +5,8 @@ block vars
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
- const url = `${config.url}/notes/${note.id}`;
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
- const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.type.isSensitive)
- const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.type.isSensitive)
block title
= `${title} | ${instanceName}`
@@ -17,7 +19,19 @@ block og
meta(property='og:title' content= title)
meta(property='og:description' content= summary)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
if video
meta(property='og:video:url' content= video.url)
meta(property='og:video:secure_url' content= video.url)
meta(property='og:video:type' content= video.type)
// FIXME: add width and height
// FIXME: add embed player for Twitter
if image
meta(property='twitter:card' content='summary_large_image')
meta(property='og:image' content= image.url)
else
meta(property='twitter:card' content='summary')
meta(property='og:image' content= avatarUrl)
block meta
if user.host || isRenote || profile.noCrawle

View File

@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= page.summary)
meta(property='og:url' content= url)
meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl)
meta(property='twitter:card' content= page.eyeCatchingImage ? 'summary_large_image' : 'summary')
block meta
if profile.noCrawle

View File

@@ -16,6 +16,7 @@ block og
meta(property='og:description' content= profile.description)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
meta(property='twitter:card' content='summary')
block meta
if user.host || profile.noCrawle

View File

@@ -0,0 +1,653 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { inspect } from 'node:util';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import type { Packed } from '@/misc/json-schema.js';
import {
signup,
post,
userList,
page,
role,
startServer,
api,
successfulApiCall,
failedApiCall,
uploadFile,
testPaginationConsistency,
} from '../utils.js';
import type * as misskey from 'misskey-js';
import type { INestApplicationContext } from '@nestjs/common';
const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
return selector(a).localeCompare(selector(b));
};
describe('アンテナ', () => {
// エンティティとしてのアンテナを主眼においたテストを記述する
// (Antennaを返すエンドポイント、Antennaエンティティを書き換えるエンドポイント、Antennaからートを取得するエンドポイントをテストする)
// BUG misskey-jsとjson-schemaが一致していない。
// - srcのenumにgroupが残っている
// - userGroupIdが残っている, isActiveがない
type Antenna = misskey.entities.Antenna | Packed<'Antenna'>;
type User = misskey.entities.MeDetailed & { token: string };
type Note = misskey.entities.Note;
// アンテナを作成できる最小のパラメタ
const defaultParam = {
caseSensitive: false,
excludeKeywords: [['']],
keywords: [['keyword']],
name: 'test',
notify: false,
src: 'all' as const,
userListId: null,
users: [''],
withFile: false,
withReplies: false,
};
let app: INestApplicationContext;
let root: User;
let alice: User;
let bob: User;
let carol: User;
let alicePost: Note;
let aliceList: misskey.entities.UserList;
let bobFile: misskey.entities.DriveFile;
let bobList: misskey.entities.UserList;
let userNotExplorable: User;
let userLocking: User;
let userSilenced: User;
let userSuspended: User;
let userDeletedBySelf: User;
let userDeletedByAdmin: User;
let userFollowingAlice: User;
let userFollowedByAlice: User;
let userBlockingAlice: User;
let userBlockedByAlice: User;
let userMutingAlice: User;
let userMutedByAlice: User;
beforeAll(async () => {
app = await startServer();
}, 1000 * 60 * 2);
beforeAll(async () => {
root = await signup({ username: 'root' });
alice = await signup({ username: 'alice' });
alicePost = await post(alice, { text: 'test' });
aliceList = await userList(alice, {});
bob = await signup({ username: 'bob' });
aliceList = await userList(alice, {});
bobFile = (await uploadFile(bob)).body;
bobList = await userList(bob);
carol = await signup({ username: 'carol' });
await api('users/lists/push', { listId: aliceList.id, userId: bob.id }, alice);
await api('users/lists/push', { listId: aliceList.id, userId: carol.id }, alice);
userNotExplorable = await signup({ username: 'userNotExplorable' });
await post(userNotExplorable, { text: 'test' });
await api('i/update', { isExplorable: false }, userNotExplorable);
userLocking = await signup({ username: 'userLocking' });
await post(userLocking, { text: 'test' });
await api('i/update', { isLocked: true }, userLocking);
userSilenced = await signup({ username: 'userSilenced' });
await post(userSilenced, { text: 'test' });
const roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } });
await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root);
userSuspended = await signup({ username: 'userSuspended' });
await post(userSuspended, { text: 'test' });
await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended });
await api('admin/suspend-user', { userId: userSuspended.id }, root);
userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' });
await post(userDeletedBySelf, { text: 'test' });
await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf);
userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' });
await post(userDeletedByAdmin, { text: 'test' });
await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root);
userFollowedByAlice = await signup({ username: 'userFollowedByAlice' });
await post(userFollowedByAlice, { text: 'test' });
await api('following/create', { userId: userFollowedByAlice.id }, alice);
userFollowingAlice = await signup({ username: 'userFollowingAlice' });
await post(userFollowingAlice, { text: 'test' });
await api('following/create', { userId: alice.id }, userFollowingAlice);
userBlockingAlice = await signup({ username: 'userBlockingAlice' });
await post(userBlockingAlice, { text: 'test' });
await api('blocking/create', { userId: alice.id }, userBlockingAlice);
userBlockedByAlice = await signup({ username: 'userBlockedByAlice' });
await post(userBlockedByAlice, { text: 'test' });
await api('blocking/create', { userId: userBlockedByAlice.id }, alice);
userMutingAlice = await signup({ username: 'userMutingAlice' });
await post(userMutingAlice, { text: 'test' });
await api('mute/create', { userId: alice.id }, userMutingAlice);
userMutedByAlice = await signup({ username: 'userMutedByAlice' });
await post(userMutedByAlice, { text: 'test' });
await api('mute/create', { userId: userMutedByAlice.id }, alice);
}, 1000 * 60 * 10);
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
// テスト間で影響し合わないように毎回全部消す。
for (const user of [alice, bob]) {
const list = await api('/antennas/list', {}, user);
for (const antenna of list.body) {
await api('/antennas/delete', { antennaId: antenna.id }, user);
}
}
});
//#region 作成(antennas/create)
test('が作成できること、キーが過不足なく入っていること。', async () => {
const response = await successfulApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam },
user: alice,
});
assert.match(response.id, /[0-9a-z]{10}/);
const expected = {
id: response.id,
caseSensitive: false,
createdAt: new Date(response.createdAt).toISOString(),
excludeKeywords: [['']],
hasUnreadNote: false,
isActive: true,
keywords: [['keyword']],
name: 'test',
notify: false,
src: 'all',
userListId: null,
users: [''],
withFile: false,
withReplies: false,
} as Antenna;
assert.deepStrictEqual(response, expected);
});
test('が上限いっぱいまで作成できること', async () => {
// antennaLimit + 1まで作れるのがキモ
const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam },
user: alice,
})));
const expected = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice });
assert.deepStrictEqual(
response.sort(compareBy(s => s.id)),
expected.sort(compareBy(s => s.id)));
failedApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam },
user: alice,
}, {
status: 400,
code: 'TOO_MANY_ANTENNAS',
id: 'faf47050-e8b5-438c-913c-db2b1576fde4',
});
});
test('を作成するとき他人のリストを指定したらエラーになる', async () => {
failedApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, src: 'list', userListId: bobList.id },
user: alice,
}, {
status: 400,
code: 'NO_SUCH_USER_LIST',
id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f',
});
});
const antennaParamPattern = [
{ parameters: (): object => ({ name: 'x'.repeat(100) }) },
{ parameters: (): object => ({ name: 'x' }) },
{ parameters: (): object => ({ src: 'home' }) },
{ parameters: (): object => ({ src: 'all' }) },
{ parameters: (): object => ({ src: 'users' }) },
{ parameters: (): object => ({ src: 'list' }) },
{ parameters: (): object => ({ userListId: null }) },
{ parameters: (): object => ({ src: 'list', userListId: aliceList.id }) },
{ parameters: (): object => ({ keywords: [['x']] }) },
{ parameters: (): object => ({ keywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) },
{ parameters: (): object => ({ excludeKeywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) },
{ parameters: (): object => ({ users: [alice.username] }) },
{ parameters: (): object => ({ users: [alice.username, bob.username, carol.username] }) },
{ parameters: (): object => ({ caseSensitive: false }) },
{ parameters: (): object => ({ caseSensitive: true }) },
{ parameters: (): object => ({ withReplies: false }) },
{ parameters: (): object => ({ withReplies: true }) },
{ parameters: (): object => ({ withFile: false }) },
{ parameters: (): object => ({ withFile: true }) },
{ parameters: (): object => ({ notify: false }) },
{ parameters: (): object => ({ notify: true }) },
];
test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => {
const response = await successfulApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, ...parameters() },
user: alice,
});
const expected = { ...response, ...parameters() };
assert.deepStrictEqual(response, expected);
});
//#endregion
//#region 更新(antennas/update)
test.each(antennaParamPattern)('を変更できること($#)', async ({ parameters }) => {
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
const response = await successfulApiCall({
endpoint: 'antennas/update',
parameters: { antennaId: antenna.id, ...defaultParam, ...parameters() },
user: alice,
});
const expected = { ...response, ...parameters() };
assert.deepStrictEqual(response, expected);
});
test.todo('は他人のものは変更できない');
test('を変更するとき他人のリストを指定したらエラーになる', async () => {
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
failedApiCall({
endpoint: 'antennas/update',
parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: bobList.id },
user: alice,
}, {
status: 400,
code: 'NO_SUCH_USER_LIST',
id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
});
});
//#endregion
//#region 表示(antennas/show)
test('をID指定で表示できること。', async () => {
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
const response = await successfulApiCall({
endpoint: 'antennas/show',
parameters: { antennaId: antenna.id },
user: alice,
});
const expected = { ...antenna };
assert.deepStrictEqual(response, expected);
});
test.todo('は他人のものをID指定で表示できない');
//#endregion
//#region 一覧(antennas/list)
test('をリスト形式で取得できること。', async () => {
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: bob });
const response = await successfulApiCall({
endpoint: 'antennas/list',
parameters: {},
user: alice,
});
const expected = [{ ...antenna }];
assert.deepStrictEqual(response, expected);
});
//#endregion
//#region 削除(antennas/delete)
test('を削除できること。', async () => {
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
const response = await successfulApiCall({
endpoint: 'antennas/delete',
parameters: { antennaId: antenna.id },
user: alice,
});
assert.deepStrictEqual(response, null);
const list = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice });
assert.deepStrictEqual(list, []);
});
test.todo('は他人のものを削除できない');
//#endregion
describe('のノート', () => {
//#region アンテナのノート取得(antennas/notes)
test('を取得できること。', async () => {
const keyword = 'キーワード';
await post(bob, { text: `test ${keyword} beforehand` });
const antenna = await successfulApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, keywords: [[keyword]] },
user: alice,
});
const note = await post(bob, { text: `test ${keyword}` });
const response = await successfulApiCall({
endpoint: 'antennas/notes',
parameters: { antennaId: antenna.id },
user: alice,
});
const expected = [note];
assert.deepStrictEqual(response, expected);
});
const keyword = 'キーワード';
test.each([
{
label: '全体から',
parameters: (): object => ({ src: 'all' }),
posts: [
{ note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
],
},
{
// BUG e4144a1 以降home指定は壊れている(allと同じ)
label: 'ホーム指定はallと同じ',
parameters: (): object => ({ src: 'home' }),
posts: [
{ note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
],
},
{
// https://github.com/misskey-dev/misskey/issues/9025
label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) },
],
},
{
label: 'ブロックしているユーザーのノートは含む',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userBlockedByAlice, { text: `${keyword}` }), included: true },
],
},
{
label: 'ブロックされているユーザーのノートは含まない',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userBlockingAlice, { text: `${keyword}` }) },
],
},
{
label: 'ミュートしているユーザーのノートは含まない',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userMutedByAlice, { text: `${keyword}` }) },
],
},
{
label: 'ミュートされているユーザーのノートは含む',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userMutingAlice, { text: `${keyword}` }), included: true },
],
},
{
label: '「見つけやすくする」がOFFのユーザーのートも含まれる',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userNotExplorable, { text: `${keyword}` }), included: true },
],
},
{
label: '鍵付きユーザーのノートも含まれる',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userLocking, { text: `${keyword}` }), included: true },
],
},
{
label: 'サイレンスのノートも含まれる',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userSilenced, { text: `${keyword}` }), included: true },
],
},
{
label: '削除ユーザーのノートも含まれる',
parameters: (): object => ({}),
posts: [
{ note: (): Promise<Note> => post(userDeletedBySelf, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(userDeletedByAdmin, { text: `${keyword}` }), included: true },
],
},
{
label: 'ユーザー指定で',
parameters: (): object => ({ src: 'users', users: [`@${bob.username}`, `@${carol.username}`] }),
posts: [
{ note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
],
},
{
label: 'リスト指定で',
parameters: (): object => ({ src: 'list', userListId: aliceList.id }),
posts: [
{ note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
],
},
{
label: 'CWにもマッチする',
parameters: (): object => ({ keywords: [[keyword]] }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: 'test', cw: `cw ${keyword}` }), included: true },
],
},
{
label: 'キーワード1つ',
parameters: (): object => ({ keywords: [[keyword]] }),
posts: [
{ note: (): Promise<Note> => post(alice, { text: 'test' }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(carol, { text: 'test' }) },
],
},
{
label: 'キーワード3つ(AND)',
parameters: (): object => ({ keywords: [['A', 'B', 'C']] }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: 'test A' }) },
{ note: (): Promise<Note> => post(bob, { text: 'test A B' }) },
{ note: (): Promise<Note> => post(bob, { text: 'test B C' }) },
{ note: (): Promise<Note> => post(bob, { text: 'test A B C' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'test C B A A B C' }), included: true },
],
},
{
label: 'キーワード3つ(OR)',
parameters: (): object => ({ keywords: [['A'], ['B'], ['C']] }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: 'test' }) },
{ note: (): Promise<Note> => post(bob, { text: 'test A' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'test A B' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'test B C' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'test B C A' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'test C B' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'test C' }), included: true },
],
},
{
label: '除外ワード3つ(AND)',
parameters: (): object => ({ excludeKeywords: [['A', 'B', 'C']] }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }), included: true },
],
},
{
label: '除外ワード3つ(OR)',
parameters: (): object => ({ excludeKeywords: [['A'], ['B'], ['C']] }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }) },
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }) },
],
},
{
label: 'キーワード1つ(大文字小文字区別する)',
parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: true }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: 'keyword' }) },
{ note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }) },
{ note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true },
],
},
{
label: 'キーワード1つ(大文字小文字区別しない)',
parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: false }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: 'keyword' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true },
],
},
{
label: '除外ワード1つ(大文字小文字区別する)',
parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: true }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) },
],
},
{
label: '除外ワード1つ(大文字小文字区別しない)',
parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: false }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }) },
{ note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }) },
{ note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) },
],
},
{
label: '添付ファイルを問わない',
parameters: (): object => ({ withFile: false }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
],
},
{
label: '添付ファイル付きのみ',
parameters: (): object => ({ withFile: true }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }) },
],
},
{
label: 'リプライ以外',
parameters: (): object => ({ withReplies: false }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }) },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
],
},
{
label: 'リプライも含む',
parameters: (): object => ({ withReplies: true }),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
],
},
])('が取得できること($label', async ({ parameters, posts }) => {
const antenna = await successfulApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, keywords: [[keyword]], ...parameters() },
user: alice,
});
const notes = await posts.reduce(async (prev, current) => {
// includedに関わらずnote()は評価して投稿する。
const p = await prev;
const n = await current.note();
if (current.included) return p.concat(n);
return p;
}, Promise.resolve([] as Note[]));
// alice視点でNoteを取り直す
const expected = await Promise.all(notes.reverse().map(s => successfulApiCall({
endpoint: 'notes/show',
parameters: { noteId: s.id },
user: alice,
})));
const response = await successfulApiCall({
endpoint: 'antennas/notes',
parameters: { antennaId: antenna.id },
user: alice,
});
assert.deepStrictEqual(
response.map(({ userId, id, text }) => ({ userId, id, text })),
expected.map(({ userId, id, text }) => ({ userId, id, text })));
assert.deepStrictEqual(response, expected);
});
test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { });
test.each([
{ label: 'ID指定', offsetBy: 'id' },
// BUG sinceDate, untilDateはsinceIdや他のエンドポイントとは異なり、その時刻に一致するレコードを含んでしまう。
// { label: '日付指定', offsetBy: 'createdAt' },
] as const)('が取得でき、$labelのPaginationに一貫性があること', async ({ offsetBy }) => {
const antenna = await successfulApiCall({
endpoint: 'antennas/create',
parameters: { ...defaultParam, keywords: [[keyword]] },
user: alice,
});
const notes = await [...Array(30)].reduce(async (prev, current, index) => {
const p = await prev;
const n = await post(alice, { text: `${keyword} (${index})` });
return [n].concat(p);
}, Promise.resolve([] as Note[]));
// antennas/notesは降順のみで、昇順をサポートしない。
await testPaginationConsistency(notes, async (paginationParam) => {
return successfulApiCall({
endpoint: 'antennas/notes',
parameters: { antennaId: antenna.id, ...paginationParam },
user: alice,
}) as any as Note[];
}, offsetBy, 'desc');
});
// BUG 7日過ぎると作り直すしかない。 https://github.com/misskey-dev/misskey/issues/10476
test.todo('を取得したときActiveに戻る');
//#endregion
});
});

View File

@@ -124,6 +124,13 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
}, user);
};
export const userList = async (user: any, userList: any = {}): Promise<any> => {
const res = await api('users/lists/create', {
name: 'test',
}, user);
return res.body;
};
export const page = async (user: any, page: any = {}): Promise<any> => {
const res = await api('pages/create', {
alignCenter: false,
@@ -380,6 +387,96 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
};
};
/**
* あるAPIエンドポイントのPaginationが複数の条件で一貫した挙動であることをテストします。
* (sinceId, untilId, sinceDate, untilDate, offset, limit)
* @param expected 期待値となるEntityの並びNote[])昇順降順が一致している必要がある
* @param fetchEntities Entity[]を返却するテスト対象のAPIを呼び出す関数
* @param offsetBy 何をキーとしてPaginationするか。
* @param ordering 昇順・降順
*/
export async function testPaginationConsistency<Entity extends { id: string, createdAt?: string }>(
expected: Entity[],
fetchEntities: (paginationParam: {
limit?: number,
offset?: number,
sinceId?: string,
untilId?: string,
sinceDate?: number,
untilDate?: number,
}) => Promise<Entity[]>,
offsetBy: 'offset' | 'id' | 'createdAt' = 'id',
ordering: 'desc' | 'asc' = 'desc'): Promise<void> {
const rangeToParam = (p: { limit?: number, until?: Entity, since?: Entity }): object => {
if (offsetBy === 'id') {
return { limit: p.limit, sinceId: p.since?.id, untilId: p.until?.id };
} else {
const sinceDate = p.since?.createdAt !== undefined ? new Date(p.since.createdAt).getTime() : undefined;
const untilDate = p.until?.createdAt !== undefined ? new Date(p.until.createdAt).getTime() : undefined;
return { limit: p.limit, sinceDate, untilDate };
}
};
for (const limit of [1, 5, 10, 100, undefined]) {
// 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること
if (ordering === 'desc') {
const end = expected[expected.length - 1];
let last = await fetchEntities(rangeToParam({ limit, since: end }));
const actual: Entity[] = [];
while (last.length !== 0) {
actual.push(...last);
last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1], since: end }));
}
actual.push(end);
assert.deepStrictEqual(
actual.map(({ id, createdAt }) => id + ':' + createdAt),
expected.map(({ id, createdAt }) => id + ':' + createdAt));
}
// 2. sinceId/Date指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
if (ordering === 'asc') {
// 昇順にしたときの先頭(一番古いもの)をもってくるexpected[1]を基準に降順にして0番目
let last = await fetchEntities({ limit: 1, untilId: expected[1].id });
const actual: Entity[] = [];
while (last.length !== 0) {
actual.push(...last);
last = await fetchEntities(rangeToParam({ limit, since: last[last.length - 1] }));
}
assert.deepStrictEqual(
actual.map(({ id, createdAt }) => id + ':' + createdAt),
expected.map(({ id, createdAt }) => id + ':' + createdAt));
}
// 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
if (ordering === 'desc') {
let last = await fetchEntities({ limit });
const actual: Entity[] = [];
while (last.length !== 0) {
actual.push(...last);
last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1] }));
}
assert.deepStrictEqual(
actual.map(({ id, createdAt }) => id + ':' + createdAt),
expected.map(({ id, createdAt }) => id + ':' + createdAt));
}
// 4. offset指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
if (offsetBy === 'offset') {
let last = await fetchEntities({ limit, offset: 0 });
let offset = limit ?? 10;
const actual: Entity[] = [];
while (last.length !== 0) {
actual.push(...last);
last = await fetchEntities({ limit, offset });
offset += limit ?? 10;
}
assert.deepStrictEqual(
actual.map(({ id, createdAt }) => id + ':' + createdAt),
expected.map(({ id, createdAt }) => id + ':' + createdAt));
}
}
}
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';

View File

@@ -62,9 +62,8 @@ module.exports = {
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
// (vue/vue3-recommended disabled the autofix for Vue 2 compatibility)
'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }],
'vue/attribute-hyphenation': ['warn', 'never'],
'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }],
'vue/attribute-hyphenation': ['error', 'never'],
},
globals: {
// Node.js

View File

@@ -397,6 +397,7 @@ function toStories(component: string): string {
Promise.all([
glob('src/components/global/*.vue'),
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),

View File

@@ -22,12 +22,12 @@
"@syuilo/aiscript": "0.13.3",
"@tabler/icons-webfont": "2.17.0",
"@vitejs/plugin-vue": "4.2.3",
"@vue-macros/reactivity-transform": "0.3.7",
"@vue/compiler-sfc": "3.3.2",
"@vue-macros/reactivity-transform": "0.3.8",
"@vue/compiler-sfc": "3.3.4",
"autosize": "6.0.1",
"blurhash": "2.0.5",
"broadcast-channel": "4.20.2",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "github:misskey-dev/buraha",
"canvas-confetti": "1.6.0",
"chart.js": "4.3.0",
"chartjs-adapter-date-fns": "3.0.0",
@@ -53,7 +53,7 @@
"punycode": "2.3.0",
"querystring": "0.2.1",
"rndstr": "1.0.0",
"rollup": "3.22.0",
"rollup": "3.23.0",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"sass": "1.62.1",
@@ -70,44 +70,44 @@
"typescript": "5.0.4",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
"vite": "4.3.7",
"vue": "3.3.2",
"vite": "4.3.8",
"vue": "3.3.4",
"vue-plyr": "7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.0.12",
"@storybook/addon-essentials": "7.0.12",
"@storybook/addon-interactions": "7.0.12",
"@storybook/addon-links": "7.0.12",
"@storybook/addon-storysource": "7.0.12",
"@storybook/addons": "7.0.12",
"@storybook/blocks": "7.0.12",
"@storybook/core-events": "7.0.12",
"@storybook/addon-actions": "7.0.15",
"@storybook/addon-essentials": "7.0.15",
"@storybook/addon-interactions": "7.0.15",
"@storybook/addon-links": "7.0.15",
"@storybook/addon-storysource": "7.0.15",
"@storybook/addons": "7.0.15",
"@storybook/blocks": "7.0.15",
"@storybook/core-events": "7.0.15",
"@storybook/jest": "0.1.0",
"@storybook/manager-api": "7.0.12",
"@storybook/preview-api": "7.0.12",
"@storybook/react": "7.0.12",
"@storybook/react-vite": "7.0.12",
"@storybook/manager-api": "7.0.15",
"@storybook/preview-api": "7.0.15",
"@storybook/react": "7.0.15",
"@storybook/react-vite": "7.0.15",
"@storybook/testing-library": "0.1.0",
"@storybook/theming": "7.0.12",
"@storybook/types": "7.0.12",
"@storybook/vue3": "7.0.12",
"@storybook/vue3-vite": "7.0.12",
"@storybook/theming": "7.0.15",
"@storybook/types": "7.0.15",
"@storybook/vue3": "7.0.15",
"@storybook/vue3-vite": "7.0.15",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
"@types/estree": "1.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.2",
"@types/matter-js": "0.18.3",
"@types/matter-js": "0.18.4",
"@types/micromatch": "4.0.2",
"@types/node": "20.1.7",
"@types/node": "20.2.3",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0",
"@types/seedrandom": "3.0.5",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/testing-library__jest-dom": "^5.14.6",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "9.0.1",
@@ -115,17 +115,17 @@
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5",
"@vitest/coverage-c8": "0.31.0",
"@vue/runtime-core": "3.3.2",
"astring": "1.8.4",
"@vitest/coverage-c8": "0.31.1",
"@vue/runtime-core": "3.3.4",
"astring": "1.8.5",
"chokidar-cli": "3.0.0",
"cross-env": "7.0.3",
"cypress": "12.12.0",
"cypress": "12.13.0",
"eslint": "8.40.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.13.0",
"eslint-plugin-vue": "9.14.0",
"fast-glob": "3.2.12",
"happy-dom": "9.18.3",
"happy-dom": "9.19.2",
"micromatch": "3.1.10",
"msw": "1.2.1",
"msw-storybook-addon": "1.8.0",
@@ -133,11 +133,11 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
"storybook": "7.0.12",
"storybook": "7.0.15",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.2",
"vitest": "0.31.0",
"vitest": "0.31.1",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.0",
"vue-tsc": "1.6.5"

View File

@@ -5,7 +5,9 @@ import '@/style.scss';
import { mainBoot } from './boot/main-boot';
import { subBoot } from './boot/sub-boot';
if (['/share', '/auth', '/miauth'].includes(location.pathname)) {
const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete'];
if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
subBoot();
} else {
mainBoot();

View File

@@ -1,5 +1,5 @@
<template>
<MkWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
<MkWindow ref="uiWindow" :initialWidth="400" :initialHeight="500" :canResize="true" @closed="emit('closed')">
<template #header>
<i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i>
<I18n :src="i18n.ts.reportAbuseOf" tag="span">
@@ -8,7 +8,7 @@
</template>
</I18n>
</template>
<MkSpacer :margin-min="20" :margin-max="28">
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m" :class="$style.root">
<div class="">
<MkTextarea v-model="comment">

View File

@@ -7,11 +7,11 @@
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { UserLite } from 'misskey-js/built/entities';
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n';
import { host as localHost } from '@/config';
import { ref } from 'vue';
import { UserLite } from 'misskey-js/built/entities';
import { api } from '@/os';
const user = ref<UserLite>();

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import MkAnalogClock from './MkAnalogClock.vue';
import isChromatic from 'chromatic';
export const Default = {
render(args) {
return {

View File

@@ -39,6 +39,7 @@
-->
<line
ref="sLine"
:class="[$style.s, { [$style.animate]: !disableSAnimate && sAnimation !== 'none', [$style.elastic]: sAnimation === 'elastic', [$style.easeOut]: sAnimation === 'easeOut' }]"
:x1="5 - (0 * (sHandLengthRatio * handsTailLength))"
:y1="5 + (1 * (sHandLengthRatio * handsTailLength))"
@@ -73,9 +74,10 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, onBeforeUnmount } from 'vue';
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import tinycolor from 'tinycolor2';
import { globalEvents } from '@/events.js';
import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
const angleDiff = (a: number, b: number) => {
@@ -145,6 +147,7 @@ let mAngle = $ref<number>(0);
let sAngle = $ref<number>(0);
let disableSAnimate = $ref(false);
let sOneRound = false;
const sLine = ref<SVGPathElement>();
function tick() {
const now = props.now();
@@ -160,17 +163,21 @@ function tick() {
}
hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
mAngle = Math.PI * (m + s / 60) / 30;
if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
if (sOneRound && sLine.value) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
sAngle = Math.PI * 60 / 30;
window.setTimeout(() => {
defaultIdlingRenderScheduler.delete(tick);
sLine.value.addEventListener('transitionend', () => {
disableSAnimate = true;
window.setTimeout(() => {
requestAnimationFrame(() => {
sAngle = 0;
window.setTimeout(() => {
requestAnimationFrame(() => {
disableSAnimate = false;
}, 100);
}, 100);
}, 700);
if (enabled) {
defaultIdlingRenderScheduler.add(tick);
}
});
});
}, { once: true });
} else {
sAngle = Math.PI * s / 30;
}
@@ -194,20 +201,13 @@ function calcColors() {
calcColors();
onMounted(() => {
const update = () => {
if (enabled) {
tick();
window.setTimeout(update, 1000);
}
};
update();
defaultIdlingRenderScheduler.add(tick);
globalEvents.on('themeChanged', calcColors);
});
onBeforeUnmount(() => {
enabled = false;
defaultIdlingRenderScheduler.delete(tick);
globalEvents.off('themeChanged', calcColors);
});
</script>

View File

@@ -11,29 +11,29 @@
<div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
</div>
<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
<MkSwitch v-else-if="c.type === 'switch'" :modelValue="valueForSwitch" @update:modelValue="onSwitchUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkSwitch>
<MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput">
<MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkTextarea>
<MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput">
<MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
<MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput">
<MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange">
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
<MkFolder v-else-if="c.type === 'folder'" :default-open="c.opened">
<MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
<template #label>{{ c.title }}</template>
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
<MkAvatar :user="user" style="width:32px;height:32px;" indicator link preview/>
<MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/>
</div>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<button
v-if="!link"
ref="el" class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.asLike]: asLike }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:type="type"
@click="emit('click', $event)"
@mousedown="onMousedown"
@@ -14,7 +14,7 @@
</button>
<MkA
v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.asLike]: asLike }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:to="to"
@mousedown="onMousedown"
>
@@ -44,6 +44,7 @@ const props = defineProps<{
full?: boolean;
small?: boolean;
large?: boolean;
transparent?: boolean;
asLike?: boolean;
}>();
@@ -194,6 +195,10 @@ function onMousedown(evt: MouseEvent): void {
}
}
&.transparent {
background: transparent;
}
&.gradate {
font-weight: bold;
color: var(--fgOnAccent) !important;

View File

@@ -1,8 +1,8 @@
<template>
<div class="cbbedffa">
<div :class="$style.root">
<canvas ref="chartEl"></canvas>
<MkChartLegend ref="legendEl" style="margin-top: 8px;"/>
<div v-if="fetching" class="fetching">
<div v-if="fetching" :class="$style.fetching">
<MkLoading/>
</div>
</div>
@@ -817,22 +817,22 @@ onMounted(() => {
/* eslint-enable id-denylist */
</script>
<style lang="scss" scoped>
.cbbedffa {
<style lang="scss" module>
.root {
position: relative;
}
> .fetching {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
-webkit-backdrop-filter: var(--blur, blur(12px));
backdrop-filter: var(--blur, blur(12px));
display: flex;
justify-content: center;
align-items: center;
cursor: wait;
}
.fetching {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
-webkit-backdrop-filter: var(--blur, blur(12px));
backdrop-filter: var(--blur, blur(12px));
display: flex;
justify-content: center;
align-items: center;
cursor: wait;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')">
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :maxWidth="340" :direction="'top'" :innerMargin="16" @closed="emit('closed')">
<div v-if="title || series">
<div v-if="title" :class="$style.title">{{ title }}</div>
<template v-if="series">

View File

@@ -6,7 +6,7 @@
<slot name="header"></slot>
</div>
<div :class="$style.headerSub">
<slot name="func" :button-style-class="$style.headerButton"></slot>
<slot name="func" :buttonStyleClass="$style.headerButton"></slot>
<button v-if="foldable" :class="$style.headerButton" class="_button" @click="() => showBody = !showBody">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
@@ -14,14 +14,14 @@
</div>
</header>
<Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
:enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
@enter="enter"
@after-enter="afterEnter"
@afterEnter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
@afterLeave="afterLeave"
>
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>

View File

@@ -1,10 +1,10 @@
<template>
<Transition
appear
:enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
:enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>

View File

@@ -4,7 +4,7 @@
:width="800"
:height="500"
:scroll="false"
:with-ok-button="true"
:withOkButton="true"
@close="cancel()"
@ok="ok()"
@closed="$emit('closed')"

View File

@@ -1,5 +1,5 @@
<template>
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root">
<div v-if="icon" :class="$style.icon">
<i :class="icon"></i>

View File

@@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import MkDigitalClock from './MkDigitalClock.vue';
export const Default = {
render(args) {
return {
components: {
MkDigitalClock,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkDigitalClock v-bind="props" />',
};
},
args: {
now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined,
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkDigitalClock>;

View File

@@ -11,19 +11,21 @@
</template>
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
const props = withDefaults(defineProps<{
showS?: boolean;
showMs?: boolean;
offset?: number;
now?: () => Date;
}>(), {
showS: true,
showMs: false,
offset: 0 - new Date().getTimezoneOffset(),
now: () => new Date(),
});
let intervalId;
const hh = ref('');
const mm = ref('');
const ss = ref('');
@@ -39,9 +41,9 @@ watch(showColon, (v) => {
}
});
const tick = () => {
const now = new Date();
now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
const tick = (): void => {
const now = props.now();
now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset);
hh.value = now.getHours().toString().padStart(2, '0');
mm.value = now.getMinutes().toString().padStart(2, '0');
ss.value = now.getSeconds().toString().padStart(2, '0');
@@ -52,13 +54,12 @@ const tick = () => {
tick();
watch(() => props.showMs, () => {
if (intervalId) window.clearInterval(intervalId);
intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
}, { immediate: true });
onMounted(() => {
defaultIdlingRenderScheduler.add(tick);
});
onUnmounted(() => {
window.clearInterval(intervalId);
defaultIdlingRenderScheduler.delete(tick);
});
</script>

View File

@@ -1,13 +1,13 @@
<template>
<div class="drylbebk"
:class="{ draghover }"
<div
:class="[$style.root, { [$style.draghover]: draghover }]"
@click="onClick"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<i v-if="folder == null" class="ti ti-cloud"></i>
<i v-if="folder == null" class="ti ti-cloud" style="margin-right: 4px;"></i>
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
</div>
</template>
@@ -130,18 +130,10 @@ function onDrop(ev: DragEvent) {
}
</script>
<style lang="scss" scoped>
.drylbebk {
> * {
pointer-events: none;
}
<style lang="scss" module>
.root {
&.draghover {
background: #eee;
}
> i {
margin-right: 4px;
}
}
</style>

View File

@@ -4,21 +4,21 @@
<div class="path" @contextmenu.prevent.stop="() => {}">
<XNavFolder
:class="{ current: folder == null }"
:parent-folder="folder"
:parentFolder="folder"
@move="move"
@upload="upload"
@remove-file="removeFile"
@remove-folder="removeFolder"
@removeFile="removeFile"
@removeFolder="removeFolder"
/>
<template v-for="f in hierarchyFolders">
<span class="separator"><i class="ti ti-chevron-right"></i></span>
<XNavFolder
:folder="f"
:parent-folder="folder"
:parentFolder="folder"
@move="move"
@upload="upload"
@remove-file="removeFile"
@remove-folder="removeFolder"
@removeFile="removeFile"
@removeFolder="removeFolder"
/>
</template>
<span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
@@ -43,13 +43,13 @@
v-anim="i"
class="folder"
:folder="f"
:select-mode="select === 'folder'"
:is-selected="selectedFolders.some(x => x.id === f.id)"
:selectMode="select === 'folder'"
:isSelected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder"
@move="move"
@upload="upload"
@remove-file="removeFile"
@remove-folder="removeFolder"
@removeFile="removeFile"
@removeFolder="removeFolder"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
@@ -64,8 +64,8 @@
v-anim="i"
class="file"
:file="file"
:select-mode="select === 'file'"
:is-selected="selectedFiles.some(x => x.id === file.id)"
:selectMode="select === 'file'"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@chosen="chooseFile"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"

View File

@@ -1,16 +1,16 @@
<template>
<div ref="thumbnail" class="zdjebgpv">
<div ref="thumbnail" :class="$style.root">
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
<i v-else-if="is === 'image'" class="ti ti-photo icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i>
<i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i>
<i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i>
<i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i>
<i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i>
<i v-else class="ti ti-file icon"></i>
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
<i v-else-if="is === 'csv'" class="ti ti-file-text" :class="$style.icon"></i>
<i v-else-if="is === 'pdf'" class="ti ti-file-text" :class="$style.icon"></i>
<i v-else-if="is === 'textfile'" class="ti ti-file-text" :class="$style.icon"></i>
<i v-else-if="is === 'archive'" class="ti ti-file-zip" :class="$style.icon"></i>
<i v-else class="ti ti-file" :class="$style.icon"></i>
<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i>
<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i>
</div>
</template>
@@ -53,28 +53,28 @@ const isThumbnailAvailable = computed(() => {
});
</script>
<style lang="scss" scoped>
.zdjebgpv {
<style lang="scss" module>
.root {
position: relative;
display: flex;
background: var(--panel);
border-radius: 8px;
overflow: clip;
}
> .icon-sub {
position: absolute;
width: 30%;
height: auto;
margin: 0;
right: 4%;
bottom: 4%;
}
.iconSub {
position: absolute;
width: 30%;
height: auto;
margin: 0;
right: 4%;
bottom: 4%;
}
> .icon {
pointer-events: none;
margin: auto;
font-size: 32px;
color: #777;
}
.icon {
pointer-events: none;
margin: auto;
font-size: 32px;
color: #777;
}
</style>

View File

@@ -3,8 +3,8 @@
ref="dialog"
:width="800"
:height="500"
:with-ok-button="true"
:ok-button-disabled="(type === 'file') && (selected.length === 0)"
:withOkButton="true"
:okButtonDisabled="(type === 'file') && (selected.length === 0)"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@@ -14,7 +14,7 @@
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
</template>
<XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/>
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
</MkModalWindow>
</template>

View File

@@ -1,15 +1,15 @@
<template>
<MkWindow
ref="window"
:initial-width="800"
:initial-height="500"
:can-resize="true"
:initialWidth="800"
:initialHeight="500"
:canResize="true"
@closed="emit('closed')"
>
<template #header>
{{ i18n.ts.drive }}
</template>
<XDrive :initial-folder="initialFolder"/>
<XDrive :initialFolder="initialFolder"/>
</MkWindow>
</template>

View File

@@ -2,10 +2,10 @@
<MkModal
ref="modal"
v-slot="{ type, maxHeight }"
:z-priority="'middle'"
:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:transparent-bg="true"
:manual-showing="manualShowing"
:zPriority="'middle'"
:preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:transparentBg="true"
:manualShowing="manualShowing"
:src="src"
@click="modal?.close()"
@opening="opening"
@@ -14,11 +14,11 @@
>
<MkEmojiPicker
ref="picker"
class="ryghynhb _popup _shadow"
:class="{ drawer: type === 'drawer' }"
:show-pinned="showPinned"
:as-reaction-picker="asReactionPicker"
:as-drawer="type === 'drawer'"
class="_popup _shadow"
:class="{ [$style.drawer]: type === 'drawer' }"
:showPinned="showPinned"
:asReactionPicker="asReactionPicker"
:asDrawer="type === 'drawer'"
:max-height="maxHeight"
@chosen="chosen"
/>
@@ -67,12 +67,10 @@ function opening() {
}
</script>
<style lang="scss" scoped>
.ryghynhb {
&.drawer {
border-radius: 24px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
<style lang="scss" module>
.drawer {
border-radius: 24px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View File

@@ -1,13 +1,14 @@
<template>
<MkWindow ref="window"
:initial-width="300"
:initial-height="290"
:can-resize="true"
<MkWindow
ref="window"
:initialWidth="300"
:initialHeight="290"
:canResize="true"
:mini="true"
:front="true"
@closed="emit('closed')"
>
<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" as-window :class="$style.picker" @chosen="chosen"/>
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow>
</template>

View File

@@ -3,14 +3,14 @@
ref="dialog"
:width="400"
:height="450"
:with-ok-button="true"
:ok-button-disabled="false"
:withOkButton="true"
:okButtonDisabled="false"
@ok="ok()"
@close="dialog.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.describeFile }}</template>
<MkSpacer :margin-min="20" :margin-max="28">
<MkSpacer :marginMin="20" :marginMax="28">
<MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
<template #label>{{ i18n.ts.caption }}</template>

View File

@@ -1,9 +1,9 @@
<template>
<div ref="el" class="ssazuxis">
<header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
<div class="title"><div><slot name="header"></slot></div></div>
<div class="divider"></div>
<button class="_button">
<div ref="el" :class="$style.root">
<header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody">
<div :class="$style.title"><div><slot name="header"></slot></div></div>
<div :class="$style.divider"></div>
<button class="_button" :class="$style.button">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
@@ -11,9 +11,9 @@
<Transition
:name="defaultStore.state.animation ? 'folder-toggle' : ''"
@enter="enter"
@after-enter="afterEnter"
@afterEnter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
@afterLeave="afterLeave"
>
<div v-show="showBody">
<slot></slot>
@@ -86,7 +86,7 @@ onMounted(() => {
});
</script>
<style lang="scss" scoped>
<style lang="scss" module>
.folder-toggle-enter-active, .folder-toggle-leave-active {
overflow-y: clip;
transition: opacity 0.5s, height 0.5s !important;
@@ -98,45 +98,41 @@ onMounted(() => {
opacity: 0;
}
.ssazuxis {
.root {
position: relative;
}
> header {
display: flex;
position: relative;
z-index: 10;
position: sticky;
top: var(--stickyTop, 0px);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(20px));
.header {
display: flex;
position: relative;
z-index: 10;
position: sticky;
top: var(--stickyTop, 0px);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(20px));
}
> .title {
display: grid;
place-content: center;
margin: 0;
padding: 12px 16px 12px 0;
}
.title {
display: grid;
place-content: center;
margin: 0;
padding: 12px 16px 12px 0;
}
> .divider {
flex: 1;
margin: auto;
height: 1px;
background: var(--divider);
}
.divider {
flex: 1;
margin: auto;
height: 1px;
background: var(--divider);
}
> button {
padding: 12px 0 12px 16px;
}
}
.button {
padding: 12px 0 12px 16px;
}
@container (max-width: 500px) {
.ssazuxis {
> header {
> .title {
padding: 8px 10px 8px 0;
}
}
.title {
padding: 8px 10px 8px 0;
}
}
</style>

View File

@@ -6,7 +6,7 @@
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div :class="$style.headerTextMain">
<MkCondensedLine :min-scale="2 / 3"><slot name="label"></slot></MkCondensedLine>
<MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
</div>
<div :class="$style.headerTextSub">
<slot name="caption"></slot>
@@ -22,18 +22,18 @@
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened">
<Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
:enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
@enter="enter"
@after-enter="afterEnter"
@afterEnter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
@afterLeave="afterLeave"
>
<KeepAlive>
<div v-show="opened">
<MkSpacer :margin-min="14" :margin-max="22">
<MkSpacer :marginMin="14" :marginMax="22">
<slot></slot>
</MkSpacer>
</div>

View File

@@ -8,27 +8,28 @@
>
<template #header>{{ i18n.ts.forgotPassword }}</template>
<form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
<div class="main _gaps_m">
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
<MkSpacer :marginMin="20" :marginMax="28">
<form v-if="instance.enableEmail" @submit.prevent="onSubmit">
<div class="_gaps_m">
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
<MkInput v-model="email" type="email" :spellcheck="false" required>
<template #label>{{ i18n.ts.emailAddress }}</template>
<template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
</MkInput>
<MkInput v-model="email" type="email" :spellcheck="false" required>
<template #label>{{ i18n.ts.emailAddress }}</template>
<template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
</MkInput>
<MkButton type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
<MkButton type="submit" rounded :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
<MkInfo>{{ i18n.ts._forgotPassword.ifNoEmail }}</MkInfo>
</div>
</form>
<div v-else>
{{ i18n.ts._forgotPassword.contactAdmin }}
</div>
<div class="sub">
<MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA>
</div>
</form>
<div v-else class="bafecedb">
{{ i18n.ts._forgotPassword.contactAdmin }}
</div>
</MkSpacer>
</MkModalWindow>
</template>
@@ -37,6 +38,7 @@ import { } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
@@ -62,20 +64,3 @@ async function onSubmit() {
dialog.close();
}
</script>
<style lang="scss" scoped>
.bafeceda {
> .main {
padding: 24px;
}
> .sub {
border-top: solid 0.5px var(--divider);
padding: 24px;
}
}
.bafecedb {
padding: 24px;
}
</style>

View File

@@ -2,9 +2,9 @@
<MkModalWindow
ref="dialog"
:width="450"
:can-close="false"
:with-ok-button="true"
:ok-button-disabled="false"
:canClose="false"
:withOkButton="true"
:okButtonDisabled="false"
@click="cancel()"
@ok="ok()"
@close="cancel()"
@@ -14,7 +14,7 @@
{{ title }}
</template>
<MkSpacer :margin-min="20" :margin-max="32">
<MkSpacer :marginMin="20" :marginMax="32">
<div class="_gaps_m">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
@@ -41,7 +41,7 @@
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
</MkRadios>
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter">
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkRange>

View File

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

View File

@@ -1,78 +0,0 @@
<template>
<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
<div class="xubzgfga">
<header>{{ image.name }}</header>
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
<footer>
<span>{{ image.type }}</span>
<span>{{ bytes(image.size) }}</span>
<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
</footer>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import MkModal from '@/components/MkModal.vue';
const props = withDefaults(defineProps<{
image: misskey.entities.DriveFile;
}>(), {
});
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const modal = $shallowRef<InstanceType<typeof MkModal>>();
</script>
<style lang="scss" scoped>
.xubzgfga {
margin: auto;
display: flex;
flex-direction: column;
height: 100%;
> header,
> footer {
align-self: center;
display: inline-block;
padding: 6px 9px;
font-size: 90%;
background: rgba(0, 0, 0, 0.5);
border-radius: 6px;
color: #fff;
}
> header {
margin-bottom: 8px;
opacity: 0.9;
}
> img {
display: block;
flex: 1;
min-height: 0;
object-fit: contain;
width: 100%;
cursor: zoom-out;
image-orientation: from-image;
}
> footer {
margin-top: 8px;
opacity: 0.8;
> span + span {
margin-left: 0.5em;
padding-left: 0.5em;
border-left: solid 1px rgba(255, 255, 255, 0.5);
}
}
}
</style>

View File

@@ -1,30 +1,56 @@
<template>
<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
<img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/>
<Transition
mode="in-out"
:enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined"
:leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined"
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
:leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
:enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined"
:leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined"
<div ref="root" :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
<TransitionGroup
:duration="defaultStore.state.animation && props.transition?.duration || undefined"
:enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
:leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_leaveActive']) || undefined"
:enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
:leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
>
<canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/>
<img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/>
</Transition>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
</TransitionGroup>
</div>
</template>
<script lang="ts" setup>
import { onMounted, shallowRef, useCssModule, watch } from 'vue';
import { decode } from 'blurhash';
import { defaultStore } from '@/store';
<script lang="ts">
import { $ref } from 'vue/macros';
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
if (_DEV_) console.log('WebGL2 in worker is supported!');
} else {
resolve(null);
if (_DEV_) console.log('WebGL2 in worker is not supported...');
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, shallowRef, useCssModule, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import { render } from 'buraha';
import { defaultStore } from '@/store';
const $style = useCssModule();
const props = withDefaults(defineProps<{
transition?: {
duration?: number | { enter: number; leave: number; };
enterActiveClass?: string;
leaveActiveClass?: string;
enterFromClass?: string;
@@ -51,67 +77,141 @@ const props = withDefaults(defineProps<{
forceBlurhash: false,
});
const viewId = uuid();
const canvas = shallowRef<HTMLCanvasElement>();
const root = shallowRef<HTMLDivElement>();
const img = shallowRef<HTMLImageElement>();
let loaded = $ref(false);
let width = $ref(props.width);
let height = $ref(props.height);
let canvasWidth = $ref(64);
let canvasHeight = $ref(64);
let imgWidth = $ref(props.width);
let imgHeight = $ref(props.height);
let bitmapTmp = $ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded || props.forceBlurhash);
function onLoad() {
loaded = true;
function waitForDecode() {
if (props.src != null && props.src !== '') {
nextTick()
.then(() => img.value?.decode())
.then(() => {
loaded = true;
}, error => {
console.error('Error occured during decoding image', img.value, error);
throw Error(error);
});
} else {
loaded = false;
}
}
watch([() => props.width, () => props.height], () => {
watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
width = Math.round(64 * ratio);
height = 64;
canvasWidth = Math.round(64 * ratio);
canvasHeight = 64;
} else {
width = 64;
height = Math.round(64 / ratio);
canvasWidth = 64;
canvasHeight = Math.round(64 / ratio);
}
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth = clientWidth;
imgHeight = Math.round(clientWidth / ratio);
}, {
immediate: true,
});
function draw() {
if (props.hash == null || !canvas.value) return;
const pixels = decode(props.hash, width, height);
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp = undefined;
const ctx = canvas.value.getContext('2d');
const imageData = ctx!.createImageData(width, height);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
}
watch([() => props.hash, canvas], () => {
async function draw() {
if (!canvas.value || props.hash == null) return;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
const workers = await workerPromise;
if (workers) {
workers.postMessage(
{
id: viewId,
hash: props.hash,
width: canvasWidth,
height: canvasHeight,
},
undefined,
);
} else {
try {
const work = document.createElement('canvas');
work.width = canvasWidth;
work.height = canvasHeight;
render(props.hash, work);
ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight);
} catch (error) {
console.error('Error occured during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
workerPromise.then(worker => {
if (worker) {
worker.addListener(workerOnMessage);
}
draw();
});
watch(() => props.src, () => {
waitForDecode();
});
watch(() => props.hash, () => {
draw();
});
onMounted(() => {
draw();
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp) {
drawImage(bitmapTmp);
}
waitForDecode();
});
onUnmounted(() => {
workerPromise.then(worker => {
worker?.removeListener(workerOnMessage);
});
});
</script>
<style lang="scss" module>
.transition_toggle_enterActive,
.transition_toggle_leaveActive {
.transition_leaveActive {
position: absolute;
top: 0;
left: 0;
}
.transition_toggle_enterTo,
.transition_toggle_leaveFrom {
opacity: 0;
}
.loader {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
}
.root {
position: relative;
width: 100%;

View File

@@ -1,9 +1,9 @@
<template>
<div class="alqyeyti" :class="{ oneline }">
<div class="key">
<div :class="[$style.root, { [$style.oneline]: oneline }]">
<div :class="$style.key">
<slot name="key"></slot>
</div>
<div class="value">
<div :class="$style.value">
<slot name="value"></slot>
<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
</div>
@@ -30,24 +30,18 @@ const copy_ = () => {
};
</script>
<style lang="scss" scoped>
.alqyeyti {
> .key {
font-size: 0.85em;
padding: 0 0 0.25em 0;
opacity: 0.75;
}
<style lang="scss" module>
.root {
&.oneline {
display: flex;
> .key {
.key {
width: 30%;
font-size: 1em;
padding: 0 8px 0 0;
}
> .value {
.value {
width: 70%;
white-space: nowrap;
overflow: hidden;
@@ -55,4 +49,10 @@ const copy_ = () => {
}
}
}
.key {
font-size: 0.85em;
padding: 0 0 0.25em 0;
opacity: 0.75;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main">
<template v-for="item in items">

View File

@@ -1,29 +1,40 @@
<template>
<div v-if="hide" :class="$style.hidden" @click="hide = false">
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</div>
<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
<a
:class="$style.imageContainer"
:href="image.url"
:title="image.name"
>
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/>
<ImgWithBlurhash
:hash="image.blurhash"
:src="(defaultStore.state.enableDataSaverMode && hide) ? null : url"
:forceBlurhash="hide"
:cover="hide"
:alt="image.comment || image.name"
:title="image.comment || image.name"
:width="image.properties.width"
:height="image.properties.height"
:style="hide ? 'filter: brightness(0.5);' : null"
/>
</a>
<div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
</div>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
<template v-if="hide">
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</template>
<template v-else>
<div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
</div>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click.stop.prevent="hide = true"><i class="ti ti-eye-off"></i></button>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
</template>
</div>
</template>
@@ -53,6 +64,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
: props.image.thumbnailUrl,
);
function onclick() {
if (hide) {
hide = false;
}
}
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
watch(() => props.image, () => {
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');

View File

@@ -7,6 +7,7 @@
:class="[
$style.medias,
count <= 4 ? $style['n' + count] : $style.nMany,
$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
@@ -19,7 +20,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref, useCssModule, watch } from 'vue';
import { onMounted, ref, useCssModule, watch, shallowRef } from 'vue';
import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
@@ -38,11 +39,42 @@ const props = defineProps<{
const $style = useCssModule();
const gallery = ref<HTMLDivElement>();
const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
function calcAspectRatio() {
if (!gallery.value) return;
let img = props.mediaList[0];
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
gallery.value.style.aspectRatio = '';
return;
}
// アスペクト比上限設定では、横長の場合は高さを縮小させる
const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
switch (defaultStore.state.mediaListWithOneImageAppearance) {
case '16_9':
gallery.value.style.aspectRatio = ratioMax(16 / 9);
break;
case '1_1':
gallery.value.style.aspectRatio = ratioMax(1);
break;
case '2_3':
gallery.value.style.aspectRatio = ratioMax(2 / 3);
break;
default:
gallery.value.style.aspectRatio = '';
break;
}
}
watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio());
onMounted(() => {
const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
@@ -162,12 +194,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
display: grid;
grid-gap: 8px;
// for webkit
height: 100%;
width: 100%;
&.n1 {
aspect-ratio: 16/9;
grid-template-rows: 1fr;
// default (expand)
min-height: 64px;
max-height: clamp(
64px,
50cqh,
min(360px, 50vh)
);
&.n116_9 {
min-height: none;
max-height: none;
aspect-ratio: 16 / 9; // fallback
}
&.n11_1{
min-height: none;
max-height: none;
aspect-ratio: 1 / 1; // fallback
}
&.n12_3 {
min-height: none;
max-height: none;
aspect-ratio: 2 / 3; // fallback
}
}
&.n2 {

View File

@@ -27,8 +27,8 @@
<script lang="ts" setup>
import { ref } from 'vue';
import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes';
import VuePlyr from 'vue-plyr';
import bytes from '@/filters/bytes';
import { defaultStore } from '@/store';
import 'vue-plyr/dist/vue-plyr.css';
import { i18n } from '@/i18n';

View File

@@ -1,6 +1,6 @@
<template>
<div ref="el" :class="$style.root">
<MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
<MkMenu :items="items" :align="align" :width="width" :asDrawer="false" @close="onChildClosed"/>
</div>
</template>

View File

@@ -50,7 +50,7 @@
</span>
</div>
<div v-if="childMenu" :class="$style.child">
<XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/>
<XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned"/>
</div>
</div>
</template>

View File

@@ -1,11 +1,11 @@
<template>
<Transition
:name="transitionName"
:enter-active-class="$style['transition_' + transitionName + '_enterActive']"
:leave-active-class="$style['transition_' + transitionName + '_leaveActive']"
:enter-from-class="$style['transition_' + transitionName + '_enterFrom']"
:leave-to-class="$style['transition_' + transitionName + '_leaveTo']"
:duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"
:enterActiveClass="$style['transition_' + transitionName + '_enterActive']"
:leaveActiveClass="$style['transition_' + transitionName + '_leaveActive']"
:enterFromClass="$style['transition_' + transitionName + '_enterFrom']"
:leaveToClass="$style['transition_' + transitionName + '_leaveTo']"
:duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
>
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>

View File

@@ -1,182 +0,0 @@
<template>
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
<div ref="rootEl" class="hrmcaedk" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
<div class="header" @contextmenu="onContextmenu">
<button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button>
<span v-else style="display: inline-block; width: 20px"></span>
<span v-if="pageMetadata?.value" class="title">
<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
<span>{{ pageMetadata?.value.title }}</span>
</span>
<button class="_button" @click="$refs.modal.close()"><i class="ti ti-x"></i></button>
</div>
<div class="body" style="container-type: inline-size;">
<MkStickyContainer>
<template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
<RouterView :router="router"/>
</MkStickyContainer>
</div>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { ComputedRef, provide } from 'vue';
import MkModal from '@/components/MkModal.vue';
import { popout as _popout } from '@/scripts/popout';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config';
import * as os from '@/os';
import { mainRouter, routes } from '@/router';
import { i18n } from '@/i18n';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
import { Router } from '@/nirax';
const props = defineProps<{
initialPath: string;
}>();
defineEmits<{
(ev: 'closed'): void;
(ev: 'click'): void;
}>();
const router = new Router(routes, props.initialPath);
router.addListener('push', ctx => {
});
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
let rootEl = $ref();
let modal = $shallowRef<InstanceType<typeof MkModal>>();
let path = $ref(props.initialPath);
let width = $ref(860);
let height = $ref(660);
const history = [];
provide('router', router);
provideMetadataReceiver((info) => {
pageMetadata = info;
});
provide('shouldOmitHeaderTitle', true);
provide('shouldHeaderThin', true);
const pageUrl = $computed(() => url + path);
const contextmenu = $computed(() => {
return [{
type: 'label',
text: path,
}, {
icon: 'ti ti-player-eject',
text: i18n.ts.showInPage,
action: expand,
}, {
icon: 'ti ti-window-maximize',
text: i18n.ts.popout,
action: popout,
}, null, {
icon: 'ti ti-external-link',
text: i18n.ts.openInNewTab,
action: () => {
window.open(pageUrl, '_blank');
modal.close();
},
}, {
icon: 'ti ti-link',
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(pageUrl);
},
}];
});
function navigate(path, record = true) {
if (record) history.push(router.getCurrentPath());
router.push(path);
}
function back() {
navigate(history.pop(), false);
}
function expand() {
mainRouter.push(path);
modal.close();
}
function popout() {
_popout(path, rootEl);
modal.close();
}
function onContextmenu(ev: MouseEvent) {
os.contextMenu(contextmenu, ev);
}
</script>
<style lang="scss" scoped>
.hrmcaedk {
margin: auto;
overflow: hidden;
display: flex;
flex-direction: column;
contain: content;
border-radius: var(--radius);
--root-margin: 24px;
@media (max-width: 500px) {
--root-margin: 16px;
}
> .header {
$height: 52px;
$height-narrow: 42px;
display: flex;
flex-shrink: 0;
height: $height;
line-height: $height;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--windowHeader);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
> button {
height: $height;
width: $height;
&:hover {
color: var(--fgHighlighted);
}
}
@media (max-width: 500px) {
height: $height-narrow;
line-height: $height-narrow;
padding-left: 16px;
> button {
height: $height-narrow;
width: $height-narrow;
}
}
> .title {
flex: 1;
> .icon {
margin-right: 0.5em;
}
}
}
> .body {
overflow: auto;
background: var(--bg);
}
}
</style>

View File

@@ -55,17 +55,17 @@
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else :class="$style.translated">
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
</div>
</div>
</div>
<div v-if="appearNote.files.length > 0" :class="$style.files">
<MkMediaList :media-list="appearNote.files"/>
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
@@ -79,7 +79,7 @@
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer :note="appearNote" :max-number="16">
<MkReactionsViewer :note="appearNote" :maxNumber="16">
<template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}

View File

@@ -65,18 +65,18 @@
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
<div v-else class="translated">
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
</div>
</div>
</div>
<div v-if="appearNote.files.length > 0" class="files">
<MkMediaList :media-list="appearNote.files"/>
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/>

View File

@@ -5,7 +5,7 @@
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emoji-urls="note.emojis"/>
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">

View File

@@ -15,7 +15,7 @@
:items="notes"
:direction="pagination.reversed ? 'up' : 'down'"
:reversed="pagination.reversed"
:no-gap="noGap"
:noGap="noGap"
:ad="true"
:class="$style.notes"
>

View File

@@ -20,8 +20,8 @@
v-else-if="notification.type === 'reaction'"
ref="reactionRef"
:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
:custom-emojis="notification.note.emojis"
:no-style="true"
:customEmojis="notification.note.emojis"
:noStyle="true"
style="width: 100%; height: 100%;"
/>
</div>

View File

@@ -3,15 +3,15 @@
ref="dialog"
:width="400"
:height="450"
:with-ok-button="true"
:ok-button-disabled="false"
:withOkButton="true"
:okButtonDisabled="false"
@ok="ok()"
@close="dialog?.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.notificationSetting }}</template>
<MkSpacer :margin-min="20" :margin-max="28">
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<template v-if="showGlobalToggle">
<MkSwitch v-model="useGlobalSetting">

View File

@@ -8,9 +8,9 @@
</template>
<template #default="{ items: notifications }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
</MkDateSeparatedList>
</template>
</MkPagination>

View File

@@ -1,12 +1,12 @@
<template>
<MkWindow
ref="windowEl"
:initial-width="500"
:initial-height="500"
:can-resize="true"
:close-button="true"
:buttons-left="buttonsLeft"
:buttons-right="buttonsRight"
:initialWidth="500"
:initialHeight="500"
:canResize="true"
:closeButton="true"
:buttonsLeft="buttonsLeft"
:buttonsRight="buttonsRight"
:contextmenu="contextmenu"
@closed="$emit('closed')"
>

View File

@@ -1,9 +1,9 @@
<template>
<Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
:enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
>
<MkLoading v-if="fetching"/>

View File

@@ -1,19 +1,19 @@
<template>
<div class="tivcixzd" :class="{ done: closed || isVoted }">
<ul>
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)">
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span>
<template v-if="choice.isVoted"><i class="ti ti-check"></i></template>
<div :class="{ [$style.done]: closed || isVoted }">
<ul :class="$style.choices">
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="[$style.choice, { [$style.voted]: choice.voted }]" @click="vote(i)">
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
<Mfm :text="choice.text" :plain="true"/>
<span v-if="showResult" class="votes">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span>
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span>
</span>
</li>
</ul>
<p v-if="!readOnly">
<p v-if="!readOnly" :class="$style.info">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
<span> · </span>
<a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
<span v-if="remaining > 0"> · {{ timer }}</span>
@@ -86,67 +86,51 @@ const vote = async (id) => {
};
</script>
<style lang="scss" scoped>
.tivcixzd {
> ul {
display: block;
margin: 0;
padding: 0;
list-style: none;
<style lang="scss" module>
.choices {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
> li {
display: block;
position: relative;
margin: 4px 0;
padding: 4px;
//border: solid 0.5px var(--divider);
background: var(--accentedBg);
border-radius: 4px;
overflow: clip;
cursor: pointer;
.choice {
display: block;
position: relative;
margin: 4px 0;
padding: 4px;
//border: solid 0.5px var(--divider);
background: var(--accentedBg);
border-radius: 4px;
overflow: clip;
cursor: pointer;
}
> .backdrop {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--accent);
background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
transition: width 1s ease;
}
.bg {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--accent);
background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
transition: width 1s ease;
}
> span {
position: relative;
display: inline-block;
padding: 3px 5px;
background: var(--panel);
border-radius: 3px;
.fg {
position: relative;
display: inline-block;
padding: 3px 5px;
background: var(--panel);
border-radius: 3px;
}
> i {
margin-right: 4px;
color: var(--accent);
}
.info {
color: var(--fg);
}
> .votes {
margin-left: 4px;
opacity: 0.7;
}
}
}
}
> p {
color: var(--fg);
a {
color: inherit;
}
}
&.done {
> ul > li {
cursor: default;
}
.done {
.choice {
cursor: default;
}
}
</style>

View File

@@ -5,7 +5,7 @@
</p>
<ul>
<li v-for="(choice, i) in choices" :key="i">
<MkInput class="input" small :model-value="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)">
<MkInput class="input" small :modelValue="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)">
</MkInput>
<button class="_button" @click="remove(i)">
<i class="ti ti-x"></i>

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