Compare commits
109 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d8f65ca426 | ||
![]() |
16b50fc6a9 | ||
![]() |
fb54c58a66 | ||
![]() |
3a924f3dc6 | ||
![]() |
11d22c7b73 | ||
![]() |
a879607479 | ||
![]() |
98aef974df | ||
![]() |
cf46816687 | ||
![]() |
eee1e74174 | ||
![]() |
8050f89d7e | ||
![]() |
406e5d297b | ||
![]() |
10634b3615 | ||
![]() |
fd03e2e1a7 | ||
![]() |
6cc86272f3 | ||
![]() |
06b1250d47 | ||
![]() |
31a7350a10 | ||
![]() |
4129ac157a | ||
![]() |
30cb791e93 | ||
![]() |
1c57983bfd | ||
![]() |
bdf08c8a54 | ||
![]() |
0513ff8b4e | ||
![]() |
62fe3bfb54 | ||
![]() |
38a1d6693a | ||
![]() |
d2eec3a9e4 | ||
![]() |
1de774fa3d | ||
![]() |
ed902658a9 | ||
![]() |
acdcd7c623 | ||
![]() |
b0344e07c4 | ||
![]() |
9a6ce1e867 | ||
![]() |
22a6bd6b22 | ||
![]() |
38e6f3f776 | ||
![]() |
ca75afe065 | ||
![]() |
915ed39715 | ||
![]() |
81fd94e635 | ||
![]() |
05507a4bea | ||
![]() |
d177f97928 | ||
![]() |
30cb03a40d | ||
![]() |
c685989e67 | ||
![]() |
ee3f408c7d | ||
![]() |
1eb35dd5bc | ||
![]() |
15db0b8812 | ||
![]() |
1b78c6a309 | ||
![]() |
c713af8e23 | ||
![]() |
bd6666173a | ||
![]() |
02715f5d14 | ||
![]() |
acd5e0b8f6 | ||
![]() |
be2142bb13 | ||
![]() |
4a703d7cf6 | ||
![]() |
95470a40a7 | ||
![]() |
56d4658b36 | ||
![]() |
f68008b002 | ||
![]() |
6a5ef5b6f2 | ||
![]() |
95b9284e79 | ||
![]() |
8317772436 | ||
![]() |
0c0ae6ff90 | ||
![]() |
d63b943116 | ||
![]() |
dddbc1c894 | ||
![]() |
f68c743f39 | ||
![]() |
59255e11b8 | ||
![]() |
3804c6e7ad | ||
![]() |
527a13b77d | ||
![]() |
a3423bad60 | ||
![]() |
e3f9144608 | ||
![]() |
231506772a | ||
![]() |
db1098a180 | ||
![]() |
7ce569424a | ||
![]() |
9b5b3a4d1b | ||
![]() |
6173cebdca | ||
![]() |
6bb82cda37 | ||
![]() |
dd60f1a533 | ||
![]() |
45263f4cd0 | ||
![]() |
747d323584 | ||
![]() |
e707aadbcc | ||
![]() |
7b012967d9 | ||
![]() |
e126083e10 | ||
![]() |
cbca48846c | ||
![]() |
ca2ed0a59b | ||
![]() |
5d22e113b2 | ||
![]() |
ed3c137543 | ||
![]() |
a35f0d43e4 | ||
![]() |
153eed7d71 | ||
![]() |
0d8e1c5421 | ||
![]() |
d10d5a8d53 | ||
![]() |
23f106a0c1 | ||
![]() |
a7ee4aabcb | ||
![]() |
d867fc00b6 | ||
![]() |
60f504bbe2 | ||
![]() |
f4e6d73a8a | ||
![]() |
c066013c57 | ||
![]() |
86f952e659 | ||
![]() |
d36e44bc57 | ||
![]() |
93629fb29d | ||
![]() |
9ff088a830 | ||
![]() |
55190e6b8b | ||
![]() |
38391010af | ||
![]() |
3d94dec7a9 | ||
![]() |
636428c72e | ||
![]() |
0717afc312 | ||
![]() |
3d4a90b08a | ||
![]() |
7cc555fd28 | ||
![]() |
238d0fa667 | ||
![]() |
30ff4592cc | ||
![]() |
a979fb9207 | ||
![]() |
f15f60d5b9 | ||
![]() |
8c97c54cfa | ||
![]() |
89a3195dfd | ||
![]() |
1eaf287b9c | ||
![]() |
9166a58c5f | ||
![]() |
eef368abd0 |
34
.github/workflows/storybook.yml
vendored
34
.github/workflows/storybook.yml
vendored
@@ -16,12 +16,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
if: github.event_name != 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Checkout HEAD
|
||||
- uses: actions/checkout@v3.3.0
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: git checkout ${{ github.head_ref }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
ref: "refs/pull/${{ github.event.number }}/merge"
|
||||
- name: Checkout actual HEAD
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: rev
|
||||
run: |
|
||||
echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT
|
||||
git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3)
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
@@ -68,7 +78,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: chromatic_pull_request
|
||||
run: |
|
||||
DIFF="${{ github.base_ref }} HEAD"
|
||||
DIFF="${{ steps.rev.outputs.base }} HEAD"
|
||||
if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then
|
||||
DIFF="HEAD"
|
||||
fi
|
||||
@@ -76,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
|
||||
@@ -91,18 +105,6 @@ jobs:
|
||||
commit_sha: context.sha,
|
||||
body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).'
|
||||
})
|
||||
- name: Notify that Chromatic will skip testing
|
||||
uses: actions/github-script@v6.4.0
|
||||
if: github.event_name == 'pull_request_target' && steps.chromatic_pull_request.outputs.skip == 'true'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: 'Chromatic will skip testing but you may still have to [review the changes on Chromatic](https://www.chromatic.com/pullrequests?appId=6428f7d7b962f0b79f97d6e4).'
|
||||
})
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@@ -12,6 +12,34 @@
|
||||
|
||||
-->
|
||||
|
||||
## 13.13.0
|
||||
|
||||
### General
|
||||
- カスタム絵文字ごとにそれをリアクションとして使えるロールを設定できるように
|
||||
- カスタム絵文字ごとに連合するかどうか設定できるように
|
||||
- カスタム絵文字ごとにセンシティブフラグを設定できるように
|
||||
- センシティブなカスタム絵文字のリアクションを受け入れない設定が可能に
|
||||
- タイムラインにフォロイーの行った他人へのリプライを含めるかどうかの設定をアカウントに保存するのをやめるように
|
||||
- 今後はAPI呼び出し時およびストリーミング接続時に設定するようになります
|
||||
- リストを公開できるようになりました
|
||||
|
||||
### Client
|
||||
- リアクションの取り消し/変更時に確認ダイアログを出すように
|
||||
- 開発者モードを追加
|
||||
- AiScriptを0.13.3に更新
|
||||
- Deck UIを使用している場合、`/`以外にアクセスした際にZen UIで表示するように
|
||||
- メインカラムを設置していない場合の問題を解決
|
||||
- ハッシュタグのノート一覧ページから、そのハッシュタグで投稿するボタンを追加
|
||||
- アカウント初期設定ウィザードに戻るボタンを追加
|
||||
- アカウントの初期設定ウィザードにあとでボタンを追加
|
||||
- Fix: URLプレビューで情報が取得できなかった際の挙動を修正
|
||||
- Fix: Safari、Firefoxでの新規登録時、パスワードマネージャーにメールアドレスが登録されていた挙動を修正
|
||||
- fix:ロールタイムラインが無効でも投稿が流れてしまう問題の修正
|
||||
- fix:ロールタイムラインにて全ての投稿が流れてしまう問題の修正
|
||||
|
||||
### Server
|
||||
- Fix: お知らせの画像URLを空にできない問題を修正
|
||||
|
||||
## 13.12.2
|
||||
|
||||
## NOTE
|
||||
@@ -87,6 +115,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
||||
* 画像が全て隠れた状態で表示されるようになります
|
||||
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
|
||||
- モデレーターはノートに添付された画像上から直接NSFW設定できるように
|
||||
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
|
||||
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
|
||||
- 新しい実績を追加
|
||||
- AiScriptを0.13.2に更新
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
@@ -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
72
locales/generateDTS.js
Normal 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
2149
locales/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,8 @@ addToList: "リストに追加"
|
||||
sendMessage: "メッセージを送信"
|
||||
copyRSS: "RSSをコピー"
|
||||
copyUsername: "ユーザー名をコピー"
|
||||
copyUserId: "ユーザーIDをコピー"
|
||||
copyNoteId: "ノートIDをコピー"
|
||||
searchUser: "ユーザーを検索"
|
||||
reply: "返信"
|
||||
loadMore: "もっと見る"
|
||||
@@ -790,6 +792,7 @@ noMaintainerInformationWarning: "管理者情報が設定されていません
|
||||
noBotProtectionWarning: "Botプロテクションが設定されていません。"
|
||||
configure: "設定する"
|
||||
postToGallery: "ギャラリーへ投稿"
|
||||
postToHashtag: "このハッシュタグで投稿"
|
||||
gallery: "ギャラリー"
|
||||
recentPosts: "最近の投稿"
|
||||
popularPosts: "人気の投稿"
|
||||
@@ -823,6 +826,7 @@ translatedFrom: "{x}から翻訳"
|
||||
accountDeletionInProgress: "アカウントの削除が進行中です"
|
||||
usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更することは出来ません。"
|
||||
aiChanMode: "藍モード"
|
||||
devMode: "開発者モード"
|
||||
keepCw: "CWを維持する"
|
||||
pubSub: "Pub/Subのアカウント"
|
||||
lastCommunication: "直近の通信"
|
||||
@@ -987,7 +991,9 @@ postToTheChannel: "チャンネルに投稿"
|
||||
cannotBeChangedLater: "後から変更できません。"
|
||||
reactionAcceptance: "リアクションの受け入れ"
|
||||
likeOnly: "いいねのみ"
|
||||
likeOnlyForRemote: "リモートからはいいねのみ"
|
||||
likeOnlyForRemote: "全て (リモートはいいねのみ)"
|
||||
nonSensitiveOnly: "非センシティブのみ"
|
||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)"
|
||||
rolesAssignedToMe: "自分に割り当てられたロール"
|
||||
resetPasswordConfirm: "パスワードリセットしますか?"
|
||||
sensitiveWords: "センシティブワード"
|
||||
@@ -1045,6 +1051,15 @@ preventAiLearning: "生成AIによる学習を拒否"
|
||||
preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したノートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。"
|
||||
options: "オプション"
|
||||
specifyUser: "ユーザー指定"
|
||||
failedToPreviewUrl: "プレビューできません"
|
||||
update: "更新"
|
||||
rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
|
||||
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ロールの指定が一つもない場合、誰でもリアクションとして使えます。"
|
||||
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "ロールは公開ロールである必要があります。"
|
||||
cancelReactionConfirm: "リアクションを取り消しますか?"
|
||||
changeReactionConfirm: "リアクションを変更しますか?"
|
||||
later: "あとで"
|
||||
goToMisskey: "Misskeyへ"
|
||||
|
||||
_initialAccountSetting:
|
||||
accountCreated: "アカウントの作成が完了しました!"
|
||||
@@ -1060,6 +1075,7 @@ _initialAccountSetting:
|
||||
haveFun: "{name}をお楽しみください!"
|
||||
ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。"
|
||||
skipAreYouSure: "初期設定をスキップしますか?"
|
||||
laterAreYouSure: "初期設定をあとでやり直しますか?"
|
||||
|
||||
_serverRules:
|
||||
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
|
||||
|
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "13.12.2",
|
||||
"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"
|
||||
},
|
||||
|
13
packages/backend/migration/1683847157541-UserList.js
Normal file
13
packages/backend/migration/1683847157541-UserList.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export class UserList1683847157541 {
|
||||
name = 'UserList1683847157541'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_list" ADD "isPublic" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_48a00f08598662b9ca540521eb" ON "user_list" ("isPublic") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_48a00f08598662b9ca540521eb"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "isPublic"`);
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
export class UserListFavorites1683869758873 {
|
||||
name = 'UserListFavorites1683869758873'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "user_list_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_c0974b21e18502a4c8178e09fe6" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_016f613dc4feb807e03e3e7da9" ON "user_list_favorite" ("userId") `);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d6765a8c2a4c17c33f9d7f948b" ON "user_list_favorite" ("userId", "userListId") `);
|
||||
await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_016f613dc4feb807e03e3e7da92" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_016f613dc4feb807e03e3e7da92"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_d6765a8c2a4c17c33f9d7f948b"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_016f613dc4feb807e03e3e7da9"`);
|
||||
await queryRunner.query(`DROP TABLE "user_list_favorite"`);
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
export class RemoveShowTimelineReplies1684206886988 {
|
||||
name = 'RemoveShowTimelineReplies1684206886988'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
}
|
15
packages/backend/migration/1684386446061-emoji-improve.js
Normal file
15
packages/backend/migration/1684386446061-emoji-improve.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export class EmojiImprove1684386446061 {
|
||||
name = 'EmojiImprove1684386446061'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "emoji" ADD "localOnly" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "emoji" ADD "isSensitive" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "emoji" ADD "roleIdsThatCanBeUsedThisEmojiAsReaction" character varying(128) array NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "roleIdsThatCanBeUsedThisEmojiAsReaction"`);
|
||||
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "isSensitive"`);
|
||||
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "localOnly"`);
|
||||
}
|
||||
}
|
@@ -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",
|
||||
|
@@ -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,
|
||||
|
@@ -144,7 +144,7 @@ export function loadConfig() {
|
||||
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
|
||||
const clientManifest = clientManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/init.ts': { file: 'src/init.ts' } };
|
||||
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
@@ -165,7 +165,7 @@ export function loadConfig() {
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
mixin.clientEntry = clientManifest['src/init.ts'];
|
||||
mixin.clientEntry = clientManifest['src/_boot_.ts'];
|
||||
mixin.clientManifestExists = clientManifestExists;
|
||||
|
||||
const externalMediaProxy = config.mediaProxy ?
|
||||
|
@@ -7,7 +7,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import type { EmojisRepository, Role } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
@@ -15,6 +15,8 @@ import type { Config } from '@/config.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Serialized } from '@/server/api/stream/types.js';
|
||||
|
||||
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
private cache: MemoryKVCache<Emoji | null>;
|
||||
@@ -63,6 +65,9 @@ export class CustomEmojiService {
|
||||
aliases: string[];
|
||||
host: string | null;
|
||||
license: string | null;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: Role['id'][];
|
||||
}): Promise<Emoji> {
|
||||
const emoji = await this.emojisRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
@@ -75,6 +80,9 @@ export class CustomEmojiService {
|
||||
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
|
||||
type: data.driveFile.webpublicType ?? data.driveFile.type,
|
||||
license: data.license,
|
||||
isSensitive: data.isSensitive,
|
||||
localOnly: data.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
if (data.host == null) {
|
||||
@@ -90,10 +98,14 @@ export class CustomEmojiService {
|
||||
|
||||
@bindThis
|
||||
public async update(id: Emoji['id'], data: {
|
||||
driveFile?: DriveFile;
|
||||
name?: string;
|
||||
category?: string | null;
|
||||
aliases?: string[];
|
||||
license?: string | null;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction?: Role['id'][];
|
||||
}): Promise<void> {
|
||||
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
|
||||
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
|
||||
@@ -105,6 +117,12 @@ export class CustomEmojiService {
|
||||
category: data.category,
|
||||
aliases: data.aliases,
|
||||
license: data.license,
|
||||
isSensitive: data.isSensitive,
|
||||
localOnly: data.localOnly,
|
||||
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
|
||||
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
|
||||
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
|
||||
});
|
||||
|
||||
this.localEmojisCache.refresh();
|
||||
@@ -259,7 +277,7 @@ export class CustomEmojiService {
|
||||
|
||||
@bindThis
|
||||
public parseEmojiStr(emojiName: string, noteUserHost: string | null) {
|
||||
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
|
||||
const match = emojiName.match(parseEmojiStrRegexp);
|
||||
if (!match) return { name: null, host: null };
|
||||
|
||||
const name = match[1];
|
||||
|
@@ -83,7 +83,7 @@ export class MfmService {
|
||||
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
|
||||
text += txt;
|
||||
// メンション
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
|
||||
const part = txt.split('@');
|
||||
|
||||
if (part.length === 2 && href) {
|
||||
|
@@ -208,7 +208,7 @@ export class QueryService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void {
|
||||
public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<User, 'id'> | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
@@ -217,7 +217,7 @@ export class QueryService {
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
} else if (!me.showTimelineReplies) {
|
||||
} else if (!withReplies) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
|
||||
|
@@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
const FALLBACK = '❤';
|
||||
|
||||
@@ -54,6 +55,9 @@ type DecodedReaction = {
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||
const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
|
||||
|
||||
@Injectable()
|
||||
export class ReactionService {
|
||||
constructor(
|
||||
@@ -72,6 +76,7 @@ export class ReactionService {
|
||||
private utilityService: UtilityService,
|
||||
private metaService: MetaService,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private roleService: RoleService,
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
@@ -85,7 +90,7 @@ export class ReactionService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string | null) {
|
||||
public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, _reaction?: string | null) {
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
|
||||
@@ -99,10 +104,41 @@ export class ReactionService {
|
||||
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
|
||||
}
|
||||
|
||||
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) {
|
||||
let reaction = _reaction ?? FALLBACK;
|
||||
|
||||
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
|
||||
reaction = '❤️';
|
||||
} else {
|
||||
reaction = await this.toDbReaction(reaction, user.host);
|
||||
} else if (_reaction) {
|
||||
const custom = reaction.match(isCustomEmojiRegexp);
|
||||
if (custom) {
|
||||
const reacterHost = this.utilityService.toPunyNullable(user.host);
|
||||
|
||||
const name = custom[1];
|
||||
const emoji = reacterHost == null
|
||||
? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
|
||||
: await this.emojisRepository.findOneBy({
|
||||
host: reacterHost,
|
||||
name,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
reaction = FALLBACK;
|
||||
}
|
||||
} else {
|
||||
reaction = this.normalize(reaction ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
const record: NoteReaction = {
|
||||
@@ -288,11 +324,9 @@ export class ReactionService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
|
||||
public normalize(reaction: string | null): string {
|
||||
if (reaction == null) return FALLBACK;
|
||||
|
||||
reacterHost = this.utilityService.toPunyNullable(reacterHost);
|
||||
|
||||
// 文字列タイプのリアクションを絵文字に変換
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
|
||||
@@ -306,25 +340,12 @@ export class ReactionService {
|
||||
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
|
||||
}
|
||||
|
||||
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
|
||||
if (custom) {
|
||||
const name = custom[1];
|
||||
const emoji = reacterHost == null
|
||||
? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
|
||||
: await this.emojisRepository.findOneBy({
|
||||
host: reacterHost,
|
||||
name,
|
||||
});
|
||||
|
||||
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
|
||||
}
|
||||
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public decodeReaction(str: string): DecodedReaction {
|
||||
const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
|
||||
const custom = str.match(decodeCustomEmojiRegexp);
|
||||
|
||||
if (custom) {
|
||||
const name = custom[1];
|
||||
|
@@ -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({}));
|
||||
|
@@ -16,6 +16,9 @@ type IWebFinger = {
|
||||
subject: string;
|
||||
};
|
||||
|
||||
const urlRegex = /^https?:\/\//;
|
||||
const mRegex = /^([^@]+)@(.*)/;
|
||||
|
||||
@Injectable()
|
||||
export class WebfingerService {
|
||||
constructor(
|
||||
@@ -35,12 +38,12 @@ export class WebfingerService {
|
||||
|
||||
@bindThis
|
||||
private genUrl(query: string): string {
|
||||
if (query.match(/^https?:\/\//)) {
|
||||
if (query.match(urlRegex)) {
|
||||
const u = new URL(query);
|
||||
return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
|
||||
}
|
||||
|
||||
const m = query.match(/^([^@]+)@(.*)/);
|
||||
const m = query.match(mRegex);
|
||||
if (m) {
|
||||
const hostname = m[2];
|
||||
const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';
|
||||
|
@@ -277,7 +277,7 @@ export class ApRendererService {
|
||||
const name = reaction.replaceAll(':', '');
|
||||
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
|
||||
|
||||
if (emoji) object.tag = [this.renderEmoji(emoji)];
|
||||
if (emoji && !emoji.localOnly) object.tag = [this.renderEmoji(emoji)];
|
||||
}
|
||||
|
||||
return object;
|
||||
@@ -400,7 +400,7 @@ export class ApRendererService {
|
||||
}));
|
||||
|
||||
const emojis = await this.getEmojis(note.emojis);
|
||||
const apemojis = emojis.map(emoji => this.renderEmoji(emoji));
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const tag = [
|
||||
...hashtagTags,
|
||||
@@ -479,7 +479,7 @@ export class ApRendererService {
|
||||
}
|
||||
|
||||
const emojis = await this.getEmojis(user.emojis);
|
||||
const apemojis = emojis.map(emoji => this.renderEmoji(emoji));
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag));
|
||||
|
||||
|
@@ -32,6 +32,8 @@ import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
@@ -42,8 +44,6 @@ import type { ApLoggerService } from '../ApLoggerService.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { ApImageService } from './ApImageService.js';
|
||||
import type { IActor, IObject } from '../type.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
@@ -306,7 +306,6 @@ export class ApPersonService implements OnModuleInit {
|
||||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
showTimelineReplies: false,
|
||||
})) as RemoteUser;
|
||||
|
||||
await transactionalEntityManager.save(new UserProfile({
|
||||
@@ -696,7 +695,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
|
||||
return 'skip: dst.alsoKnownAs is empty';
|
||||
}
|
||||
if (!dst.alsoKnownAs?.includes(src.uri)) {
|
||||
if (!dst.alsoKnownAs.includes(src.uri)) {
|
||||
return 'skip: alsoKnownAs does not include from.uri';
|
||||
}
|
||||
|
||||
|
@@ -26,6 +26,8 @@ export class EmojiEntityService {
|
||||
category: emoji.category,
|
||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
url: emoji.publicUrl || emoji.originalUrl,
|
||||
isSensitive: emoji.isSensitive ? true : undefined,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +53,9 @@ export class EmojiEntityService {
|
||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
url: emoji.publicUrl || emoji.originalUrl,
|
||||
license: emoji.license,
|
||||
isSensitive: emoji.isSensitive,
|
||||
localOnly: emoji.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -466,7 +466,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
mutedInstances: profile!.mutedInstances,
|
||||
mutingNotificationTypes: profile!.mutingNotificationTypes,
|
||||
emailNotificationTypes: profile!.emailNotificationTypes,
|
||||
showTimelineReplies: user.showTimelineReplies ?? falsy,
|
||||
achievements: profile!.achievements,
|
||||
loggedInDays: profile!.loggedInDates.length,
|
||||
policies: this.roleService.getUserPolicies(user.id),
|
||||
|
@@ -35,6 +35,7 @@ export class UserListEntityService {
|
||||
createdAt: userList.createdAt.toISOString(),
|
||||
name: userList.name,
|
||||
userIds: users.map(x => x.userId),
|
||||
isPublic: userList.isPublic,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -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'),
|
||||
|
@@ -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,
|
||||
|
@@ -60,4 +60,20 @@ export class Emoji {
|
||||
length: 1024, nullable: true,
|
||||
})
|
||||
public license: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public localOnly: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isSensitive: boolean;
|
||||
|
||||
// TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする
|
||||
@Column('varchar', {
|
||||
array: true, length: 128, default: '{}',
|
||||
})
|
||||
public roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -232,12 +232,6 @@ export class User {
|
||||
})
|
||||
public followersUri: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether to show users replying to other users in the timeline.',
|
||||
})
|
||||
public showTimelineReplies: boolean;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column('char', {
|
||||
length: 16, nullable: true, unique: true,
|
||||
|
@@ -19,6 +19,12 @@ export class UserList {
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isPublic: boolean;
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
|
33
packages/backend/src/models/entities/UserListFavorite.ts
Normal file
33
packages/backend/src/models/entities/UserListFavorite.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { id } from '../id.js';
|
||||
import { User } from './User.js';
|
||||
import { UserList } from './UserList.js';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'userListId'], { unique: true })
|
||||
export class UserListFavorite {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone')
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column(id())
|
||||
public userListId: UserList['id'];
|
||||
|
||||
@ManyToOne(type => UserList, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public userList: UserList | null;
|
||||
}
|
@@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js';
|
||||
import { UserIp } from '@/models/entities/UserIp.js';
|
||||
import { 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>;
|
||||
|
@@ -22,6 +22,19 @@ export const packedEmojiSimpleSchema = {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isSensitive: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -63,5 +76,22 @@ export const packedEmojiDetailedSchema = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
isSensitive: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localOnly: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@@ -25,5 +25,10 @@ export const packedUserListSchema = {
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
isPublic: {
|
||||
type: 'boolean',
|
||||
nullable: false,
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@@ -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,
|
||||
|
@@ -107,6 +107,9 @@ export class ImportCustomEmojisProcessorService {
|
||||
aliases: emojiInfo.aliases,
|
||||
driveFile,
|
||||
license: emojiInfo.license,
|
||||
isSensitive: emojiInfo.isSensitive,
|
||||
localOnly: emojiInfo.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -585,7 +585,7 @@ export class ActivityPubServerService {
|
||||
name: request.params.emoji,
|
||||
});
|
||||
|
||||
if (emoji == null) {
|
||||
if (emoji == null || emoji.localOnly) {
|
||||
reply.code(404);
|
||||
return;
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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],
|
||||
|
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -25,9 +25,24 @@ export const meta = {
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
category: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Use `null` to reset the category.',
|
||||
},
|
||||
aliases: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
license: { type: 'string', nullable: true },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
required: ['fileId'],
|
||||
required: ['name', 'fileId'],
|
||||
} as const;
|
||||
|
||||
// TODO: ロジックをサービスに切り出す
|
||||
@@ -45,18 +60,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
|
||||
const name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;
|
||||
|
||||
const emoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
name,
|
||||
category: null,
|
||||
aliases: [],
|
||||
name: ps.name,
|
||||
category: ps.category ?? null,
|
||||
aliases: ps.aliases ?? [],
|
||||
host: null,
|
||||
license: null,
|
||||
license: ps.license ?? null,
|
||||
isSensitive: ps.isSensitive ?? false,
|
||||
localOnly: ps.localOnly ?? false,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
|
||||
});
|
||||
|
||||
this.moderationLogService.insertModerationLog(me, 'addEmoji', {
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -15,6 +17,11 @@ export const meta = {
|
||||
code: 'NO_SUCH_EMOJI',
|
||||
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
|
||||
},
|
||||
noSuchFile: {
|
||||
message: 'No such file.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d',
|
||||
},
|
||||
sameNameEmojiExists: {
|
||||
message: 'Emoji that have same name already exists.',
|
||||
code: 'SAME_NAME_EMOJI_EXISTS',
|
||||
@@ -28,6 +35,7 @@ export const paramDef = {
|
||||
properties: {
|
||||
id: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
category: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
@@ -37,6 +45,11 @@ export const paramDef = {
|
||||
type: 'string',
|
||||
} },
|
||||
license: { type: 'string', nullable: true },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
required: ['id', 'name', 'aliases'],
|
||||
} as const;
|
||||
@@ -45,14 +58,28 @@ export const paramDef = {
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private customEmojiService: CustomEmojiService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let driveFile;
|
||||
|
||||
if (ps.fileId) {
|
||||
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
|
||||
await this.customEmojiService.update(ps.id, {
|
||||
driveFile,
|
||||
name: ps.name,
|
||||
category: ps.category ?? null,
|
||||
aliases: ps.aliases,
|
||||
license: ps.license ?? null,
|
||||
isSensitive: ps.isSensitive,
|
||||
localOnly: ps.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -141,7 +141,6 @@ export const paramDef = {
|
||||
preventAiLearning: { type: 'boolean' },
|
||||
isBot: { type: 'boolean' },
|
||||
isCat: { type: 'boolean' },
|
||||
showTimelineReplies: { type: 'boolean' },
|
||||
injectFeaturedNote: { type: 'boolean' },
|
||||
receiveAnnouncementEmail: { type: 'boolean' },
|
||||
alwaysMarkNsfw: { type: 'boolean' },
|
||||
@@ -239,7 +238,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
|
||||
if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions;
|
||||
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
|
||||
if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies;
|
||||
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
|
||||
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
|
||||
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
|
||||
|
@@ -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 },
|
||||
|
@@ -34,11 +34,8 @@ export const meta = {
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Only show notes that have attached files.',
|
||||
},
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
@@ -78,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateRepliesQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
|
@@ -46,11 +46,8 @@ export const paramDef = {
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { type: 'boolean', default: true },
|
||||
includeLocalRenotes: { type: 'boolean', default: true },
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Only show notes that have attached files.',
|
||||
},
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -98,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
this.queryService.generateChannelQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
|
@@ -36,11 +36,8 @@ export const meta = {
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Only show notes that have attached files.',
|
||||
},
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
fileType: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
@@ -86,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateChannelQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
if (me) this.queryService.generateMutedNoteQuery(query, me);
|
||||
|
@@ -35,11 +35,8 @@ export const paramDef = {
|
||||
includeMyRenotes: { type: 'boolean', default: true },
|
||||
includeRenotedMyNotes: { type: 'boolean', default: true },
|
||||
includeLocalRenotes: { type: 'boolean', default: true },
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Only show notes that have attached files.',
|
||||
},
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -84,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}
|
||||
|
||||
this.queryService.generateChannelQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, ps.withReplies, me);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
|
@@ -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')
|
||||
|
@@ -0,0 +1,148 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { UserList } from '@/models/entities/UserList.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { UserListService } from '@/core/UserListService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'UserList',
|
||||
},
|
||||
|
||||
errors: {
|
||||
tooManyUserLists: {
|
||||
message: 'You cannot create user list any more.',
|
||||
code: 'TOO_MANY_USERLISTS',
|
||||
id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f',
|
||||
},
|
||||
noSuchList: {
|
||||
message: 'No such list.',
|
||||
code: 'NO_SUCH_LIST',
|
||||
id: '9292f798-6175-4f7d-93f4-b6742279667d',
|
||||
},
|
||||
noSuchUser: {
|
||||
message: 'No such user.',
|
||||
code: 'NO_SUCH_USER',
|
||||
id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f',
|
||||
},
|
||||
|
||||
alreadyAdded: {
|
||||
message: 'That user has already been added to that list.',
|
||||
code: 'ALREADY_ADDED',
|
||||
id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615',
|
||||
},
|
||||
|
||||
youHaveBeenBlocked: {
|
||||
message: 'You cannot push this user because you have been blocked by this user.',
|
||||
code: 'YOU_HAVE_BEEN_BLOCKED',
|
||||
id: 'a2497f2a-2389-439c-8626-5298540530f4',
|
||||
},
|
||||
|
||||
tooManyUsers: {
|
||||
message: 'You can not push users any more.',
|
||||
code: 'TOO_MANY_USERS',
|
||||
id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
listId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['name', 'listId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
private userListService: UserListService,
|
||||
private userListEntityService: UserListEntityService,
|
||||
private idService: IdService,
|
||||
private getterService: GetterService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const list = await this.userListsRepository.findOneBy({
|
||||
id: ps.listId,
|
||||
isPublic: true,
|
||||
});
|
||||
if (list === null) throw new ApiError(meta.errors.noSuchList);
|
||||
const currentCount = await this.userListsRepository.countBy({
|
||||
userId: me.id,
|
||||
});
|
||||
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) {
|
||||
throw new ApiError(meta.errors.tooManyUserLists);
|
||||
}
|
||||
|
||||
const userList = await this.userListsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: me.id,
|
||||
name: ps.name,
|
||||
} as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const users = (await this.userListJoiningsRepository.findBy({
|
||||
userListId: ps.listId,
|
||||
})).map(x => x.userId);
|
||||
|
||||
for (const user of users) {
|
||||
const currentUser = await this.getterService.getUser(user).catch(err => {
|
||||
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (currentUser.id !== me.id) {
|
||||
const block = await this.blockingsRepository.findOneBy({
|
||||
blockerId: currentUser.id,
|
||||
blockeeId: me.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
const exist = await this.userListJoiningsRepository.findOneBy({
|
||||
userListId: userList.id,
|
||||
userId: currentUser.id,
|
||||
});
|
||||
|
||||
if (exist) {
|
||||
throw new ApiError(meta.errors.alreadyAdded);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.userListService.push(currentUser, userList, me);
|
||||
} catch (err) {
|
||||
if (err instanceof UserListService.TooManyUsersError) {
|
||||
throw new ApiError(meta.errors.tooManyUsers);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return await this.userListEntityService.pack(userList);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
errors: {
|
||||
noSuchList: {
|
||||
message: 'No such user list.',
|
||||
code: 'NO_SUCH_USER_LIST',
|
||||
id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe',
|
||||
},
|
||||
|
||||
alreadyFavorited: {
|
||||
message: 'The list has already been favorited.',
|
||||
code: 'ALREADY_FAVORITED',
|
||||
id: '6425bba0-985b-461e-af1b-518070e72081',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
listId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['listId'],
|
||||
} as const;
|
||||
|
||||
@Injectable() // eslint-disable-next-line import/no-default-export
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor (
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListFavoritesRepository)
|
||||
private userListFavoritesRepository: UserListFavoritesRepository,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const userList = await this.userListsRepository.findOneBy({
|
||||
id: ps.listId,
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
if (userList === null) {
|
||||
throw new ApiError(meta.errors.noSuchList);
|
||||
}
|
||||
|
||||
const exist = await this.userListFavoritesRepository.findOneBy({
|
||||
userId: me.id,
|
||||
userListId: ps.listId,
|
||||
});
|
||||
|
||||
if (exist !== null) {
|
||||
throw new ApiError(meta.errors.alreadyFavorited);
|
||||
}
|
||||
|
||||
await this.userListFavoritesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: me.id,
|
||||
userListId: ps.listId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,13 +1,14 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import 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)));
|
||||
|
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,63 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
errors: {
|
||||
noSuchList: {
|
||||
message: 'No such user list.',
|
||||
code: 'NO_SUCH_USER_LIST',
|
||||
id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b',
|
||||
},
|
||||
|
||||
notFavorited: {
|
||||
message: 'You have not favorited the list.',
|
||||
code: 'ALREADY_FAVORITED',
|
||||
id: '835c4b27-463d-4cfa-969b-a9058678d465',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
listId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['listId'],
|
||||
} as const;
|
||||
|
||||
@Injectable() // eslint-disable-next-line import/no-default-export
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor (
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListFavoritesRepository)
|
||||
private userListFavoritesRepository: UserListFavoritesRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const userList = await this.userListsRepository.findOneBy({
|
||||
id: ps.listId,
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
if (userList === null) {
|
||||
throw new ApiError(meta.errors.noSuchList);
|
||||
}
|
||||
|
||||
const exist = await this.userListFavoritesRepository.findOneBy({
|
||||
userListId: ps.listId,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
if (exist === null) {
|
||||
throw new ApiError(meta.errors.notFavorited);
|
||||
}
|
||||
|
||||
await this.userListFavoritesRepository.delete({ id: exist.id });
|
||||
});
|
||||
}
|
||||
}
|
@@ -34,8 +34,9 @@ export const paramDef = {
|
||||
properties: {
|
||||
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);
|
||||
|
@@ -13,6 +13,7 @@ class GlobalTimelineChannel extends Channel {
|
||||
public readonly chName = 'globalTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@@ -31,6 +32,8 @@ class GlobalTimelineChannel extends Channel {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.gtlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
@@ -54,7 +57,7 @@ class GlobalTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
|
@@ -11,6 +11,7 @@ class HomeTimelineChannel extends Channel {
|
||||
public readonly chName = 'homeTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
@@ -24,6 +25,8 @@ class HomeTimelineChannel extends Channel {
|
||||
|
||||
@bindThis
|
||||
public async init(params: any) {
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ class HomeTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
|
@@ -13,6 +13,7 @@ class HybridTimelineChannel extends Channel {
|
||||
public readonly chName = 'hybridTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@@ -31,6 +32,8 @@ class HybridTimelineChannel extends Channel {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
@@ -75,7 +78,7 @@ class HybridTimelineChannel extends Channel {
|
||||
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
|
@@ -12,6 +12,7 @@ class LocalTimelineChannel extends Channel {
|
||||
public readonly chName = 'localTimeline';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@@ -30,6 +31,8 @@ class LocalTimelineChannel extends Channel {
|
||||
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
|
||||
if (!policies.ltlAvailable) return;
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on('notesStream', this.onNote);
|
||||
}
|
||||
@@ -54,7 +57,7 @@ class LocalTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && this.user && !this.user.showTimelineReplies) {
|
||||
if (note.reply && this.user && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
|
||||
|
@@ -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,
|
||||
);
|
||||
|
@@ -246,7 +246,7 @@ export default class Connection {
|
||||
|
||||
const ch: Channel = channelService.create(id, this);
|
||||
this.channels.push(ch);
|
||||
ch.init(params);
|
||||
ch.init(params ?? {});
|
||||
|
||||
if (pong) {
|
||||
this.sendMessageToWs('connected', {
|
||||
|
@@ -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%;
|
||||
}
|
||||
`)
|
||||
|
@@ -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
|
||||
|
@@ -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')
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
653
packages/backend/test/e2e/antennas.ts
Normal file
653
packages/backend/test/e2e/antennas.ts
Normal 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
|
||||
});
|
||||
});
|
@@ -43,7 +43,6 @@ describe('ユーザー', () => {
|
||||
|
||||
type MeDetailed = UserDetailedNotMe &
|
||||
misskey.entities.MeDetailed & {
|
||||
showTimelineReplies: boolean,
|
||||
achievements: object[],
|
||||
loggedInDays: number,
|
||||
policies: object,
|
||||
@@ -160,7 +159,6 @@ describe('ユーザー', () => {
|
||||
mutedInstances: user.mutedInstances,
|
||||
mutingNotificationTypes: user.mutingNotificationTypes,
|
||||
emailNotificationTypes: user.emailNotificationTypes,
|
||||
showTimelineReplies: user.showTimelineReplies,
|
||||
achievements: user.achievements,
|
||||
loggedInDays: user.loggedInDays,
|
||||
policies: user.policies,
|
||||
@@ -406,7 +404,6 @@ describe('ユーザー', () => {
|
||||
assert.deepStrictEqual(response.mutedInstances, []);
|
||||
assert.deepStrictEqual(response.mutingNotificationTypes, []);
|
||||
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
|
||||
assert.strictEqual(response.showTimelineReplies, false);
|
||||
assert.deepStrictEqual(response.achievements, []);
|
||||
assert.deepStrictEqual(response.loggedInDays, 0);
|
||||
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
|
||||
@@ -470,8 +467,6 @@ describe('ユーザー', () => {
|
||||
{ parameters: (): object => ({ isBot: false }) },
|
||||
{ parameters: (): object => ({ isCat: true }) },
|
||||
{ parameters: (): object => ({ isCat: false }) },
|
||||
{ parameters: (): object => ({ showTimelineReplies: true }) },
|
||||
{ parameters: (): object => ({ showTimelineReplies: false }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: true }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: false }) },
|
||||
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
|
||||
|
@@ -15,78 +15,74 @@ describe('ReactionService', () => {
|
||||
reactionService = app.get<ReactionService>(ReactionService);
|
||||
});
|
||||
|
||||
describe('toDbReaction', () => {
|
||||
describe('normalize', () => {
|
||||
test('絵文字リアクションはそのまま', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('👍'), '👍');
|
||||
assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅');
|
||||
assert.strictEqual(await reactionService.normalize('👍'), '👍');
|
||||
assert.strictEqual(await reactionService.normalize('🍅'), '🍅');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する pudding', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮');
|
||||
assert.strictEqual(await reactionService.normalize('pudding'), '🍮');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する like', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('like'), '👍');
|
||||
assert.strictEqual(await reactionService.normalize('like'), '👍');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する love', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('love'), '❤');
|
||||
assert.strictEqual(await reactionService.normalize('love'), '❤');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する laugh', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆');
|
||||
assert.strictEqual(await reactionService.normalize('laugh'), '😆');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する hmm', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔');
|
||||
assert.strictEqual(await reactionService.normalize('hmm'), '🤔');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する surprise', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮');
|
||||
assert.strictEqual(await reactionService.normalize('surprise'), '😮');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する congrats', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉');
|
||||
assert.strictEqual(await reactionService.normalize('congrats'), '🎉');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する angry', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('angry'), '💢');
|
||||
assert.strictEqual(await reactionService.normalize('angry'), '💢');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する confused', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('confused'), '😥');
|
||||
assert.strictEqual(await reactionService.normalize('confused'), '😥');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する rip', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('rip'), '😇');
|
||||
assert.strictEqual(await reactionService.normalize('rip'), '😇');
|
||||
});
|
||||
|
||||
test('既存のリアクションは絵文字化する star', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('star'), '⭐');
|
||||
assert.strictEqual(await reactionService.normalize('star'), '⭐');
|
||||
});
|
||||
|
||||
test('異体字セレクタ除去', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗');
|
||||
assert.strictEqual(await reactionService.normalize('㊗️'), '㊗');
|
||||
});
|
||||
|
||||
test('異体字セレクタ除去 必要なし', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗');
|
||||
});
|
||||
|
||||
test('fallback - undefined', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(undefined), '❤');
|
||||
assert.strictEqual(await reactionService.normalize('㊗'), '㊗');
|
||||
});
|
||||
|
||||
test('fallback - null', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(null), '❤');
|
||||
assert.strictEqual(await reactionService.normalize(null), '❤');
|
||||
});
|
||||
|
||||
test('fallback - empty', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction(''), '❤');
|
||||
assert.strictEqual(await reactionService.normalize(''), '❤');
|
||||
});
|
||||
|
||||
test('fallback - unknown', async () => {
|
||||
assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤');
|
||||
assert.strictEqual(await reactionService.normalize('unknown'), '❤');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -62,8 +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/v-on-event-hyphenation': ['error', 'never', { autofix: true }],
|
||||
'vue/attribute-hyphenation': ['error', 'never'],
|
||||
},
|
||||
globals: {
|
||||
// Node.js
|
||||
|
@@ -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'),
|
||||
|
@@ -19,15 +19,15 @@
|
||||
"@rollup/plugin-json": "6.0.0",
|
||||
"@rollup/plugin-replace": "5.0.2",
|
||||
"@rollup/pluginutils": "5.0.2",
|
||||
"@syuilo/aiscript": "0.13.2",
|
||||
"@syuilo/aiscript": "0.13.3",
|
||||
"@tabler/icons-webfont": "2.17.0",
|
||||
"@vitejs/plugin-vue": "4.2.2",
|
||||
"@vue-macros/reactivity-transform": "0.3.6",
|
||||
"@vue/compiler-sfc": "3.3.1",
|
||||
"autosize": "5.0.2",
|
||||
"blurhash": "2.0.5",
|
||||
"@vitejs/plugin-vue": "4.2.3",
|
||||
"@vue-macros/reactivity-transform": "0.3.8",
|
||||
"@vue/compiler-sfc": "3.3.4",
|
||||
"autosize": "6.0.1",
|
||||
"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.21.6",
|
||||
"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.5",
|
||||
"vue": "3.3.1",
|
||||
"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.10",
|
||||
"@storybook/addon-essentials": "7.0.10",
|
||||
"@storybook/addon-interactions": "7.0.10",
|
||||
"@storybook/addon-links": "7.0.10",
|
||||
"@storybook/addon-storysource": "7.0.10",
|
||||
"@storybook/addons": "7.0.10",
|
||||
"@storybook/blocks": "7.0.10",
|
||||
"@storybook/core-events": "7.0.10",
|
||||
"@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.10",
|
||||
"@storybook/preview-api": "7.0.10",
|
||||
"@storybook/react": "7.0.10",
|
||||
"@storybook/react-vite": "7.0.10",
|
||||
"@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.10",
|
||||
"@storybook/types": "7.0.10",
|
||||
"@storybook/vue3": "7.0.10",
|
||||
"@storybook/vue3-vite": "7.0.10",
|
||||
"@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.3",
|
||||
"@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.1",
|
||||
"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.12.0",
|
||||
"eslint-plugin-vue": "9.14.0",
|
||||
"fast-glob": "3.2.12",
|
||||
"happy-dom": "9.16.0",
|
||||
"happy-dom": "9.19.2",
|
||||
"micromatch": "3.1.10",
|
||||
"msw": "1.2.1",
|
||||
"msw-storybook-addon": "1.8.0",
|
||||
@@ -133,13 +133,13 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"storybook": "7.0.10",
|
||||
"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.2.1",
|
||||
"vue-tsc": "1.6.4"
|
||||
"vue-eslint-parser": "9.3.0",
|
||||
"vue-tsc": "1.6.5"
|
||||
}
|
||||
}
|
||||
|
14
packages/frontend/src/_boot_.ts
Normal file
14
packages/frontend/src/_boot_.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// https://vitejs.dev/config/build-options.html#build-modulepreload
|
||||
import 'vite/modulepreload-polyfill';
|
||||
|
||||
import '@/style.scss';
|
||||
import { mainBoot } from './boot/main-boot';
|
||||
import { subBoot } from './boot/sub-boot';
|
||||
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete'];
|
||||
|
||||
if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
|
||||
subBoot();
|
||||
} else {
|
||||
mainBoot();
|
||||
}
|
@@ -3,11 +3,11 @@ import * as misskey from 'misskey-js';
|
||||
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
|
||||
import { i18n } from './i18n';
|
||||
import { miLocalStorage } from './local-storage';
|
||||
import { MenuButton } from './types/menu';
|
||||
import { del, get, set } from '@/scripts/idb-proxy';
|
||||
import { apiUrl } from '@/config';
|
||||
import { waiting, api, popup, popupMenu, success, alert } from '@/os';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
|
||||
import { MenuButton } from './types/menu';
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
@@ -101,57 +101,57 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
|
||||
if (res.status >= 500 && res.status < 600) {
|
||||
.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
|
||||
if (res.status >= 500 && res.status < 600) {
|
||||
// サーバーエラー(5xx)の場合をrejectとする
|
||||
// (認証エラーなど4xxはresolve)
|
||||
return fail2(res);
|
||||
}
|
||||
res.json().then(done2, fail2);
|
||||
}))
|
||||
.then(async res => {
|
||||
if (res.error) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
return fail2(res);
|
||||
}
|
||||
res.json().then(done2, fail2);
|
||||
}))
|
||||
.then(async res => {
|
||||
if (res.error) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
// SUSPENDED
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await showSuspendedDialog();
|
||||
}
|
||||
} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await showSuspendedDialog();
|
||||
}
|
||||
} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
|
||||
// USER_IS_DELETED
|
||||
// アカウントが削除されている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.accountDeleted,
|
||||
text: i18n.ts.accountDeletedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.accountDeleted,
|
||||
text: i18n.ts.accountDeletedDescription,
|
||||
});
|
||||
}
|
||||
} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
|
||||
// AUTHENTICATION_FAILED
|
||||
// トークンが無効化されていたりアカウントが削除されたりしている
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.tokenRevoked,
|
||||
text: i18n.ts.tokenRevokedDescription,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.tokenRevoked,
|
||||
text: i18n.ts.tokenRevokedDescription,
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
|
||||
// rejectかつ理由がtrueの場合、削除対象であることを示す
|
||||
fail(true);
|
||||
} else {
|
||||
(res as Account).token = token;
|
||||
done(res as Account);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
// rejectかつ理由がtrueの場合、削除対象であることを示す
|
||||
fail(true);
|
||||
} else {
|
||||
(res as Account).token = token;
|
||||
done(res as Account);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,3 +305,7 @@ export async function openAccountMenu(opts: {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (_DEV_) {
|
||||
(window as any).$i = $i;
|
||||
}
|
||||
|
263
packages/frontend/src/boot/common.ts
Normal file
263
packages/frontend/src/boot/common.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent, App } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import JSON5 from 'json5';
|
||||
import widgets from '@/widgets';
|
||||
import directives from '@/directives';
|
||||
import components from '@/components';
|
||||
import { version, ui, lang, updateLocale } from '@/config';
|
||||
import { applyTheme } from '@/scripts/theme';
|
||||
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
|
||||
import { i18n, updateI18n } from '@/i18n';
|
||||
import { confirm, alert, post, popup, toast } from '@/os';
|
||||
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
|
||||
import { defaultStore, ColdDeviceStorage } from '@/store';
|
||||
import { fetchInstance, instance } from '@/instance';
|
||||
import { deviceKind } from '@/scripts/device-kind';
|
||||
import { reloadChannel } from '@/scripts/unison-reload';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker';
|
||||
import { getUrlWithoutLoginId } from '@/scripts/login-id';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id';
|
||||
import { deckStore } from '@/ui/deck/deck-store';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis';
|
||||
import { mainRouter } from '@/router';
|
||||
|
||||
export async function common(createVue: () => App<Element>) {
|
||||
console.info(`Misskey v${version}`);
|
||||
|
||||
if (_DEV_) {
|
||||
console.warn('Development mode!!!');
|
||||
|
||||
console.info(`vue ${vueVersion}`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).$i = $i;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).$store = defaultStore;
|
||||
|
||||
window.addEventListener('error', event => {
|
||||
console.error(event);
|
||||
/*
|
||||
alert({
|
||||
type: 'error',
|
||||
title: 'DEV: Unhandled error',
|
||||
text: event.message
|
||||
});
|
||||
*/
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
console.error(event);
|
||||
/*
|
||||
alert({
|
||||
type: 'error',
|
||||
title: 'DEV: Unhandled promise rejection',
|
||||
text: event.reason
|
||||
});
|
||||
*/
|
||||
});
|
||||
}
|
||||
|
||||
const splash = document.getElementById('splash');
|
||||
// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
|
||||
if (splash) splash.addEventListener('transitionend', () => {
|
||||
splash.remove();
|
||||
});
|
||||
|
||||
let isClientUpdated = false;
|
||||
|
||||
//#region クライアントが更新されたかチェック
|
||||
const lastVersion = miLocalStorage.getItem('lastVersion');
|
||||
if (lastVersion !== version) {
|
||||
miLocalStorage.setItem('lastVersion', version);
|
||||
|
||||
// テーマリビルドするため
|
||||
miLocalStorage.removeItem('theme');
|
||||
|
||||
try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
|
||||
if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
|
||||
isClientUpdated = true;
|
||||
}
|
||||
} catch (err) { /* empty */ }
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
const localeVersion = miLocalStorage.getItem('localeVersion');
|
||||
const localeOutdated = (localeVersion == null || localeVersion !== version);
|
||||
if (localeOutdated) {
|
||||
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
|
||||
if (res.status === 200) {
|
||||
const newLocale = await res.text();
|
||||
const parsedNewLocale = JSON.parse(newLocale);
|
||||
miLocalStorage.setItem('locale', newLocale);
|
||||
miLocalStorage.setItem('localeVersion', version);
|
||||
updateLocale(parsedNewLocale);
|
||||
updateI18n(parsedNewLocale);
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// タッチデバイスでCSSの:hoverを機能させる
|
||||
document.addEventListener('touchend', () => {}, { passive: true });
|
||||
|
||||
// 一斉リロード
|
||||
reloadChannel.addEventListener('message', path => {
|
||||
if (path !== null) location.href = path;
|
||||
else location.reload();
|
||||
});
|
||||
|
||||
// If mobile, insert the viewport meta tag
|
||||
if (['smartphone', 'tablet'].includes(deviceKind)) {
|
||||
const viewport = document.getElementsByName('viewport').item(0);
|
||||
viewport.setAttribute('content',
|
||||
`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
|
||||
}
|
||||
|
||||
//#region Set lang attr
|
||||
const html = document.documentElement;
|
||||
html.setAttribute('lang', lang);
|
||||
//#endregion
|
||||
|
||||
await defaultStore.ready;
|
||||
await deckStore.ready;
|
||||
|
||||
const fetchInstanceMetaPromise = fetchInstance();
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
miLocalStorage.setItem('v', instance.version);
|
||||
});
|
||||
|
||||
//#region loginId
|
||||
const params = new URLSearchParams(location.search);
|
||||
const loginId = params.get('loginId');
|
||||
|
||||
if (loginId) {
|
||||
const target = getUrlWithoutLoginId(location.href);
|
||||
|
||||
if (!$i || $i.id !== loginId) {
|
||||
const account = await getAccountFromId(loginId);
|
||||
if (account) {
|
||||
await login(account.token, target);
|
||||
}
|
||||
}
|
||||
|
||||
history.replaceState({ misskey: 'loginId' }, '', target);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
|
||||
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
|
||||
}, { immediate: miLocalStorage.getItem('theme') == null });
|
||||
|
||||
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
|
||||
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
|
||||
|
||||
watch(darkTheme, (theme) => {
|
||||
if (defaultStore.state.darkMode) {
|
||||
applyTheme(theme);
|
||||
}
|
||||
});
|
||||
|
||||
watch(lightTheme, (theme) => {
|
||||
if (!defaultStore.state.darkMode) {
|
||||
applyTheme(theme);
|
||||
}
|
||||
});
|
||||
|
||||
//#region Sync dark mode
|
||||
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
|
||||
defaultStore.set('darkMode', isDeviceDarkmode());
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
|
||||
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
|
||||
defaultStore.set('darkMode', mql.matches);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
if (defaultStore.state.themeInitial) {
|
||||
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme));
|
||||
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme));
|
||||
defaultStore.set('themeInitial', false);
|
||||
}
|
||||
});
|
||||
|
||||
watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
|
||||
document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
|
||||
}, { immediate: true });
|
||||
|
||||
watch(defaultStore.reactiveState.useBlurEffect, v => {
|
||||
if (v) {
|
||||
document.documentElement.style.removeProperty('--blur');
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--blur', 'none');
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
//#region Fetch user
|
||||
if ($i && $i.token) {
|
||||
if (_DEV_) {
|
||||
console.log('account cache found. refreshing...');
|
||||
}
|
||||
|
||||
refreshAccount();
|
||||
}
|
||||
//#endregion
|
||||
|
||||
try {
|
||||
await fetchCustomEmojis();
|
||||
} catch (err) { /* empty */ }
|
||||
|
||||
const app = createVue();
|
||||
|
||||
if (_DEV_) {
|
||||
app.config.performance = true;
|
||||
}
|
||||
|
||||
widgets(app);
|
||||
directives(app);
|
||||
components(app);
|
||||
|
||||
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
|
||||
// なぜか2回実行されることがあるため、mountするdivを1つに制限する
|
||||
const rootEl = ((): HTMLElement => {
|
||||
const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
|
||||
|
||||
const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
|
||||
|
||||
if (currentRoot) {
|
||||
console.warn('multiple import detected');
|
||||
return currentRoot;
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.id = MISSKEY_MOUNT_DIV_ID;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
})();
|
||||
|
||||
app.mount(rootEl);
|
||||
|
||||
// boot.jsのやつを解除
|
||||
window.onerror = null;
|
||||
window.onunhandledrejection = null;
|
||||
|
||||
removeSplash();
|
||||
|
||||
return {
|
||||
isClientUpdated,
|
||||
app,
|
||||
};
|
||||
}
|
||||
|
||||
function removeSplash() {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.style.opacity = '0';
|
||||
splash.style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
254
packages/frontend/src/boot/main-boot.ts
Normal file
254
packages/frontend/src/boot/main-boot.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
|
||||
import { common } from './common';
|
||||
import { version, ui, lang, updateLocale } from '@/config';
|
||||
import { i18n, updateI18n } from '@/i18n';
|
||||
import { confirm, alert, post, popup, toast } from '@/os';
|
||||
import { useStream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
|
||||
import { defaultStore, ColdDeviceStorage } from '@/store';
|
||||
import { makeHotkey } from '@/scripts/hotkey';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
|
||||
import { mainRouter } from '@/router';
|
||||
import { initializeSw } from '@/scripts/initialize-sw';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
|
||||
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
|
||||
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
|
||||
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
|
||||
defineAsyncComponent(() => import('@/ui/universal.vue')),
|
||||
));
|
||||
|
||||
reactionPicker.init();
|
||||
|
||||
if (isClientUpdated && $i) {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
|
||||
}
|
||||
|
||||
const stream = useStream();
|
||||
|
||||
let reloadDialogShowing = false;
|
||||
stream.on('_disconnected_', async () => {
|
||||
if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
|
||||
location.reload();
|
||||
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
|
||||
if (reloadDialogShowing) return;
|
||||
reloadDialogShowing = true;
|
||||
const { canceled } = await confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts.disconnectedFromServer,
|
||||
text: i18n.ts.reloadConfirm,
|
||||
});
|
||||
reloadDialogShowing = false;
|
||||
if (!canceled) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
|
||||
import('../plugin').then(async ({ install }) => {
|
||||
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
install(plugin);
|
||||
});
|
||||
}
|
||||
|
||||
const hotkeys = {
|
||||
'd': (): void => {
|
||||
defaultStore.set('darkMode', !defaultStore.state.darkMode);
|
||||
},
|
||||
's': (): void => {
|
||||
mainRouter.push('/search');
|
||||
},
|
||||
};
|
||||
|
||||
if ($i) {
|
||||
// only add post shortcuts if logged in
|
||||
hotkeys['p|n'] = post;
|
||||
|
||||
defaultStore.loaded.then(() => {
|
||||
if (defaultStore.state.accountSetupWizard !== -1) {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
|
||||
}
|
||||
});
|
||||
|
||||
if ($i.isDeleted) {
|
||||
alert({
|
||||
type: 'warning',
|
||||
text: i18n.ts.accountDeletionInProgress,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const m = now.getMonth() + 1;
|
||||
const d = now.getDate();
|
||||
|
||||
if ($i.birthday) {
|
||||
const bm = parseInt($i.birthday.split('-')[1]);
|
||||
const bd = parseInt($i.birthday.split('-')[2]);
|
||||
if (m === bm && d === bd) {
|
||||
claimAchievement('loggedInOnBirthday');
|
||||
}
|
||||
}
|
||||
|
||||
if (m === 1 && d === 1) {
|
||||
claimAchievement('loggedInOnNewYearsDay');
|
||||
}
|
||||
|
||||
if ($i.loggedInDays >= 3) claimAchievement('login3');
|
||||
if ($i.loggedInDays >= 7) claimAchievement('login7');
|
||||
if ($i.loggedInDays >= 15) claimAchievement('login15');
|
||||
if ($i.loggedInDays >= 30) claimAchievement('login30');
|
||||
if ($i.loggedInDays >= 60) claimAchievement('login60');
|
||||
if ($i.loggedInDays >= 100) claimAchievement('login100');
|
||||
if ($i.loggedInDays >= 200) claimAchievement('login200');
|
||||
if ($i.loggedInDays >= 300) claimAchievement('login300');
|
||||
if ($i.loggedInDays >= 400) claimAchievement('login400');
|
||||
if ($i.loggedInDays >= 500) claimAchievement('login500');
|
||||
if ($i.loggedInDays >= 600) claimAchievement('login600');
|
||||
if ($i.loggedInDays >= 700) claimAchievement('login700');
|
||||
if ($i.loggedInDays >= 800) claimAchievement('login800');
|
||||
if ($i.loggedInDays >= 900) claimAchievement('login900');
|
||||
if ($i.loggedInDays >= 1000) claimAchievement('login1000');
|
||||
|
||||
if ($i.notesCount > 0) claimAchievement('notes1');
|
||||
if ($i.notesCount >= 10) claimAchievement('notes10');
|
||||
if ($i.notesCount >= 100) claimAchievement('notes100');
|
||||
if ($i.notesCount >= 500) claimAchievement('notes500');
|
||||
if ($i.notesCount >= 1000) claimAchievement('notes1000');
|
||||
if ($i.notesCount >= 5000) claimAchievement('notes5000');
|
||||
if ($i.notesCount >= 10000) claimAchievement('notes10000');
|
||||
if ($i.notesCount >= 20000) claimAchievement('notes20000');
|
||||
if ($i.notesCount >= 30000) claimAchievement('notes30000');
|
||||
if ($i.notesCount >= 40000) claimAchievement('notes40000');
|
||||
if ($i.notesCount >= 50000) claimAchievement('notes50000');
|
||||
if ($i.notesCount >= 60000) claimAchievement('notes60000');
|
||||
if ($i.notesCount >= 70000) claimAchievement('notes70000');
|
||||
if ($i.notesCount >= 80000) claimAchievement('notes80000');
|
||||
if ($i.notesCount >= 90000) claimAchievement('notes90000');
|
||||
if ($i.notesCount >= 100000) claimAchievement('notes100000');
|
||||
|
||||
if ($i.followersCount > 0) claimAchievement('followers1');
|
||||
if ($i.followersCount >= 10) claimAchievement('followers10');
|
||||
if ($i.followersCount >= 50) claimAchievement('followers50');
|
||||
if ($i.followersCount >= 100) claimAchievement('followers100');
|
||||
if ($i.followersCount >= 300) claimAchievement('followers300');
|
||||
if ($i.followersCount >= 500) claimAchievement('followers500');
|
||||
if ($i.followersCount >= 1000) claimAchievement('followers1000');
|
||||
|
||||
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
|
||||
claimAchievement('passedSinceAccountCreated1');
|
||||
}
|
||||
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
|
||||
claimAchievement('passedSinceAccountCreated2');
|
||||
}
|
||||
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
|
||||
claimAchievement('passedSinceAccountCreated3');
|
||||
}
|
||||
|
||||
if (claimedAchievements.length >= 30) {
|
||||
claimAchievement('collectAchievements30');
|
||||
}
|
||||
|
||||
window.setInterval(() => {
|
||||
if (Math.floor(Math.random() * 20000) === 0) {
|
||||
claimAchievement('justPlainLucky');
|
||||
}
|
||||
}, 1000 * 10);
|
||||
|
||||
window.setTimeout(() => {
|
||||
claimAchievement('client30min');
|
||||
}, 1000 * 60 * 30);
|
||||
|
||||
window.setTimeout(() => {
|
||||
claimAchievement('client60min');
|
||||
}, 1000 * 60 * 60);
|
||||
|
||||
const lastUsed = miLocalStorage.getItem('lastUsed');
|
||||
if (lastUsed) {
|
||||
const lastUsedDate = parseInt(lastUsed, 10);
|
||||
// 二時間以上前なら
|
||||
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
|
||||
toast(i18n.t('welcomeBackWithName', {
|
||||
name: $i.name || $i.username,
|
||||
}));
|
||||
}
|
||||
}
|
||||
miLocalStorage.setItem('lastUsed', Date.now().toString());
|
||||
|
||||
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
|
||||
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
|
||||
if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
|
||||
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
|
||||
}
|
||||
}
|
||||
|
||||
if ('Notification' in window) {
|
||||
// 許可を得ていなかったらリクエスト
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
const main = markRaw(stream.useChannel('main', null, 'System'));
|
||||
|
||||
// 自分の情報が更新されたとき
|
||||
main.on('meUpdated', i => {
|
||||
updateAccount(i);
|
||||
});
|
||||
|
||||
main.on('readAllNotifications', () => {
|
||||
updateAccount({ hasUnreadNotification: false });
|
||||
});
|
||||
|
||||
main.on('unreadNotification', () => {
|
||||
updateAccount({ hasUnreadNotification: true });
|
||||
});
|
||||
|
||||
main.on('unreadMention', () => {
|
||||
updateAccount({ hasUnreadMentions: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadMentions', () => {
|
||||
updateAccount({ hasUnreadMentions: false });
|
||||
});
|
||||
|
||||
main.on('unreadSpecifiedNote', () => {
|
||||
updateAccount({ hasUnreadSpecifiedNotes: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadSpecifiedNotes', () => {
|
||||
updateAccount({ hasUnreadSpecifiedNotes: false });
|
||||
});
|
||||
|
||||
main.on('readAllAntennas', () => {
|
||||
updateAccount({ hasUnreadAntenna: false });
|
||||
});
|
||||
|
||||
main.on('unreadAntenna', () => {
|
||||
updateAccount({ hasUnreadAntenna: true });
|
||||
sound.play('antenna');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
updateAccount({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// トークンが再生成されたとき
|
||||
// このままではMisskeyが利用できないので強制的にサインアウトさせる
|
||||
main.on('myTokenRegenerated', () => {
|
||||
signout();
|
||||
});
|
||||
}
|
||||
|
||||
// shortcut
|
||||
document.addEventListener('keydown', makeHotkey(hotkeys));
|
||||
|
||||
initializeSw();
|
||||
}
|
8
packages/frontend/src/boot/sub-boot.ts
Normal file
8
packages/frontend/src/boot/sub-boot.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
|
||||
import { common } from './common';
|
||||
|
||||
export async function subBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
defineAsyncComponent(() => import('@/ui/minimum.vue')),
|
||||
));
|
||||
}
|
@@ -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,8 +8,8 @@
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<div class="dpvffvvy _gaps_m">
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<div class="_gaps_m" :class="$style.root">
|
||||
<div class="">
|
||||
<MkTextarea v-model="comment">
|
||||
<template #label>{{ i18n.ts.details }}</template>
|
||||
@@ -60,8 +60,8 @@ function send() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dpvffvvy {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
</style>
|
||||
|
@@ -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>();
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
243
packages/frontend/src/components/MkAnimBg.vue
Normal file
243
packages/frontend/src/components/MkAnimBg.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
|
||||
const canvasEl = shallowRef<HTMLCanvasElement>();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number;
|
||||
focus?: number;
|
||||
}>(), {
|
||||
scale: 1.0,
|
||||
focus: 1.0,
|
||||
});
|
||||
|
||||
function loadShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
alert(
|
||||
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
function initShaderProgram(gl, vsSource, fsSource) {
|
||||
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
|
||||
const shaderProgram = gl.createProgram();
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
alert(
|
||||
`failed to init shader: ${gl.getProgramInfoLog(
|
||||
shaderProgram,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasEl.value!;
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
|
||||
if (gl == null) return;
|
||||
|
||||
gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, `
|
||||
attribute vec2 vertex;
|
||||
|
||||
uniform vec2 u_scale;
|
||||
|
||||
varying vec2 v_pos;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(vertex, 0.0, 1.0);
|
||||
v_pos = vertex / u_scale;
|
||||
}
|
||||
`, `
|
||||
precision mediump float;
|
||||
|
||||
vec3 mod289(vec3 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec2 mod289(vec2 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439);
|
||||
|
||||
vec2 i = floor(v + dot(v, C.yy) );
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
|
||||
vec2 i1;
|
||||
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
|
||||
i = mod289(i);
|
||||
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0 ));
|
||||
|
||||
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
||||
m = m*m ;
|
||||
m = m*m ;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
|
||||
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_spread;
|
||||
uniform float u_speed;
|
||||
uniform float u_warp;
|
||||
uniform float u_focus;
|
||||
uniform float u_itensity;
|
||||
|
||||
varying vec2 v_pos;
|
||||
|
||||
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
|
||||
float SPREAD = 0.7 * u_spread;
|
||||
float SPEED = 0.00055 * u_speed;
|
||||
float WARP = 1.5 * u_warp;
|
||||
float FOCUS = 1.15 * u_focus;
|
||||
|
||||
vec2 dist = _pos - _origin;
|
||||
|
||||
float distortion = snoise( vec2(
|
||||
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
|
||||
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
|
||||
) ) * 0.5 + 0.5;
|
||||
|
||||
float feather = 0.01 + SPREAD * pow( distortion, FOCUS );
|
||||
|
||||
return 1.0 - smoothstep(
|
||||
_radius - ( _radius * feather ),
|
||||
_radius + ( _radius * feather ),
|
||||
dot( dist, dist ) * 4.0
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 );
|
||||
vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 );
|
||||
vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 );
|
||||
|
||||
float ratio = u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5;
|
||||
|
||||
vec3 color = vec3( 0.0 );
|
||||
|
||||
float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
|
||||
float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
|
||||
float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
|
||||
|
||||
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
|
||||
color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix );
|
||||
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
|
||||
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
|
||||
|
||||
color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
|
||||
|
||||
vec3 inverted = vec3( 1.0 ) - color;
|
||||
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
|
||||
}
|
||||
`);
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution');
|
||||
const u_time = gl.getUniformLocation(shaderProgram, 'u_time');
|
||||
const u_spread = gl.getUniformLocation(shaderProgram, 'u_spread');
|
||||
const u_speed = gl.getUniformLocation(shaderProgram, 'u_speed');
|
||||
const u_warp = gl.getUniformLocation(shaderProgram, 'u_warp');
|
||||
const u_focus = gl.getUniformLocation(shaderProgram, 'u_focus');
|
||||
const u_itensity = gl.getUniformLocation(shaderProgram, 'u_itensity');
|
||||
const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale');
|
||||
gl.uniform2fv(u_resolution, [canvas.width, canvas.height]);
|
||||
gl.uniform1f(u_spread, 1.0);
|
||||
gl.uniform1f(u_speed, 1.0);
|
||||
gl.uniform1f(u_warp, 1.0);
|
||||
gl.uniform1f(u_focus, props.focus);
|
||||
gl.uniform1f(u_itensity, 0.5);
|
||||
gl.uniform2fv(u_scale, [props.scale, props.scale]);
|
||||
|
||||
const vertex = gl.getAttribLocation(shaderProgram, 'vertex');
|
||||
gl.enableVertexAttribArray(vertex);
|
||||
gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const vertices = [1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0];
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
|
||||
|
||||
if (isChromatic()) {
|
||||
gl!.uniform1f(u_time, 0);
|
||||
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
|
||||
} else {
|
||||
function render(timeStamp) {
|
||||
gl!.uniform1f(u_time, timeStamp);
|
||||
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handle) {
|
||||
window.cancelAnimationFrame(handle);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
</style>
|
@@ -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"/>
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
||||
|
@@ -26,6 +26,3 @@ const props = withDefaults(defineProps<{
|
||||
extractor: (item) => item,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
|
@@ -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>
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
|
@@ -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')"/>
|
||||
|
@@ -4,7 +4,7 @@
|
||||
:width="800"
|
||||
:height="500"
|
||||
:scroll="false"
|
||||
:with-ok-button="true"
|
||||
:withOkButton="true"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="$emit('closed')"
|
||||
|
@@ -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>
|
||||
|
@@ -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>;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user