Compare commits

...

26 commits

Author SHA1 Message Date
c06809c93e Merge remote-tracking branch 'upstream/develop' 2024-10-26 22:34:02 -05:00
かっこかり
a6a1e3d733
enhance(frontend): Self-XSS防止用のメッセージを追加 (#14839)
* enhance(frontend): Self-XSS防止用のメッセージを追加

* Update Changelog

* embedにも同様の記述を追加
2024-10-26 22:07:26 +09:00
github-actions[bot]
ded6ef207b Bump version to 2024.10.2-alpha.1 2024-10-25 13:16:43 +00:00
かっこかり
db95b6b0d6
🎨
https://github.com/misskey-dev/misskey/pull/14828 のデザイン修正
2024-10-25 19:37:01 +09:00
かっこかり
eeea4ec00b
fix(backend): 招待コード発行可能残り数算出に使用すべきロールポリシーの値が違うのを修正 (#14834)
* fix: should use invite limit cycle to calculate invite/limit

* Update Changelog

* Update changelog

---------

Co-authored-by: Lhc_fl <lhcfl@outlook.com>
2024-10-25 15:09:37 +09:00
かっこかり
07b2c3e5b2
fix(frontend): 管理画面のリンク切れを修正 (#14831)
* fix(frontend): 管理画面のリンク切れを修正

* Update Changelog
2024-10-25 15:09:07 +09:00
かっこかり
076cc953e2
enhance(frontend): 外部アプリ認証画面の改良 (#14828)
* enhance(frontend): 外部アプリ認証画面の改良

* 🎨

* lint

* Update Changelog

* indent

* lint

* enhance: miauthのリダイレクト先をUI内でも表示するように

* 🎨

* fix

* fix
2024-10-25 14:20:33 +09:00
かっこかり
15ae1605ec
enhance(frontend): 「単なるラッキー」の調整 (#14807)
* enhance(frontend): 「単なるラッキー」の調整

* refactor

* comment

* Update Changelog

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-10-23 14:23:29 +09:00
饺子w (Yumechi)
48d1539f3b
Merge commit from fork
[ghsa-gq5q-c77c-v236](https://github.com/misskey-dev/misskey/security/advisories/ghsa-gq5q-c77c-v236)

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
2024-10-22 18:17:56 +09:00
github-actions[bot]
8b6d321a76 Bump version to 2024.10.2-alpha.0 2024-10-22 08:45:08 +00:00
syuilo
952fec5665
feat: 過去のノートを非公開化/フォロワーのみ表示可能にできる機能 (#14814)
* wip

* Update CHANGELOG.md

* wip

* wip

* wip

* Update privacy.vue

* wip
2024-10-22 17:08:53 +09:00
syuilo
70b2a8f72e fix(frontend): /iのレスポンスに含まれないプロパティが消えずに残り続ける問題を修正 2024-10-21 19:59:20 +09:00
syuilo
c4f1ca2fd9 fix(frontend): MkSelectでmodelValueが更新されない限り値を更新しないように 2024-10-21 19:14:02 +09:00
Kisaragi
9d0f7eeb9c
docs: ActivityPub層の変更を含む場合にやるべきことを明文化 (#14812) 2024-10-21 15:12:28 +09:00
かっこかり
bc1fce9af6
fix(frontend): デッキのタイムラインカラムでwithSensitiveが利用できない問題を修正 (#14772)
* fix(frontend): デッキのタイムラインカラムでwithSensitiveが利用できない問題を修正

* Update Changelog

* Update Changelog

* Update packages/frontend/src/ui/deck/tl-column.vue
2024-10-21 13:22:21 +09:00
かっこかり
5f12bc515d
Update CHANGELOG.md 2024-10-21 13:11:11 +09:00
Yuba
2f9c04b23b
refs#10866 投稿ダイアログでEscキーが押されたときIME入力中ならダイアログは閉じない (#14787) 2024-10-21 12:51:45 +09:00
syuilo
5c79d8db20
feat: ノートの閲覧にログイン必須にする設定 (#14799)
* wip

* wip

* wip

* Update packages/frontend/src/pages/note.vue

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* wip

* Update WebhookTestService.ts

* Update privacy.vue

* wip

* rename

* Update locales/ja-JP.yml

Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>

* 🎨

* wip

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
2024-10-21 12:49:29 +09:00
かっこかり
bc0c53b92b
fix(frontend): Captcha のエラーハンドリング (#14811)
* fix(frontend): Captcha のエラーハンドリングを修正 (MisskeyIO#768)

(cherry picked from commit 88912d0f8c63a762fbb1d43e5c1abf4fd9fc05d4)

* Update Changelog

* typo

---------

Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com>
2024-10-21 11:44:57 +09:00
かっこかり
d6caa4d9c4
fix(frontend): 通知の範囲指定が必要ない通知設定でも範囲指定がでている問題を修正 (#14798)
* fix(frontend): 通知の範囲指定が必要ない通知設定でも範囲指定がでている問題を修正

* Update Changelog

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-10-20 17:29:41 +09:00
syuilo
041c9caf31 🎨 2024-10-20 16:38:27 +09:00
tetsuya-ki
1d106b3ae8
Enhance: ドライブでソートができるように (#14801)
* Enhance: ドライブでソートができるように

* Update CHANGELOG.md
2024-10-20 16:17:16 +09:00
かっこかり
58419e1621
refactor(frontend): ページ内でdocument.titleを直接操作させない, タイポ修正 など (taiyme#288) (#14778)
Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
2024-10-19 21:45:25 +09:00
かっこかり
2250e521e4
refactor(frontend): getBgColorを共通化 (#14782)
* refactor: getBgColor関数の切り出し + fix types (taiyme#291)

* move thing

* revert unnecesary changes

---------

Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
2024-10-19 18:02:09 +09:00
かっこかり
a3a99467f0
enhance(frontend): Bull Dashboard に relationship queue を追加 (#14777)
* spec(frontend): Bull Dashboard に relationship queue を追加 (MisskeyIO#751)

(cherry picked from commit a8bbccbefa67ca0f2c1ec0880da88dfc7517b6a0)

* Update Changelog

* Update Changelog

---------

Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com>
2024-10-19 17:25:11 +09:00
github-actions[bot]
b1aac6acc3 [skip ci] Update CHANGELOG.md (prepend template) 2024-10-15 04:53:48 +00:00
74 changed files with 1612 additions and 456 deletions

View file

@ -1,3 +1,31 @@
## 2024.10.2
### General
- Feat: コンテンツの表示にログインを必須にできるように
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
### Client
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/751)
- Enhance: ドライブでソートができるように
- Enhance: 「単なるラッキー」の取得条件を変更
- Enhance: 投稿フォームでEscキーを押したときIME入力中ならフォームを閉じないように #10866
- Enhance: MiAuth, OAuthの認可画面の改善
- どのアカウントで認証しようとしているのかがわかるように
- 認証するアカウントを切り替えられるように
- Enhance: Self-XSS防止用の警告を追加
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正
- Fix: リンク切れを修正
### Server
- Fix: Nested proxy requestsを検出した際にブロックするように
[ghsa-gq5q-c77c-v236](https://github.com/misskey-dev/misskey/security/advisories/ghsa-gq5q-c77c-v236)
- Fix: 招待コードの発行可能な残り数算出に使用すべきロールポリシーの値が違う問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/706)
## 2024.10.1 ## 2024.10.1
### Note ### Note

View file

@ -64,6 +64,22 @@ Thank you for your PR! Before creating a PR, please check the following:
Thanks for your cooperation 🤗 Thanks for your cooperation 🤗
### Additional things for ActivityPub payload changes
*This section is specific to misskey-dev implementation. Other fork or implementation may take different way. A significant difference is that non-"misskey-dev" extension is not described in the misskey-hub's document.*
If PR includes changes to ActivityPub payload, please reflect it in [misskey-hub's document](https://github.com/misskey-dev/misskey-hub-next/blob/master/content/ns.md) by sending PR.
The name of purporsed extension property (referred as "extended property" in later) to ActivityPub shall be prefixed by `_misskey_`. (i.e. `_misskey_quote`)
The extended property in `packages/backend/src/core/activitypub/type.ts` **must** be declared as optional because ActivityPub payloads that comes from older Misskey or other implementation may not contain it.
The extended property must be included in the context definition. Context is defined in `packages/backend/src/core/activitypub/misc/contexts.ts`.
The key shall be same as the name of extended property, and the value shall be same as "short IRI".
"Short IRI" is defined in misskey-hub's document, but usually takes form of `misskey:<name of extended property>`. (i.e. `misskey:_misskey_quote`)
One should not add property that has defined before by other implementation, or add custom variant value to "well-known" property.
## Reviewers guide ## Reviewers guide
Be willing to comment on the good points and not just the things you want fixed 💯 Be willing to comment on the good points and not just the things you want fixed 💯

110
locales/index.d.ts vendored
View file

@ -3806,6 +3806,18 @@ export interface Locale extends ILocale {
* 1 * 1
*/ */
"oneMonth": string; "oneMonth": string;
/**
* 3
*/
"threeMonths": string;
/**
* 1
*/
"oneYear": string;
/**
* 3
*/
"threeDays": string;
/** /**
* *
*/ */
@ -5190,6 +5202,64 @@ export interface Locale extends ILocale {
* 使 * 使
*/ */
"yourNameContainsProhibitedWordsDescription": string; "yourNameContainsProhibitedWordsDescription": string;
/**
* 稿
*/
"thisContentsAreMarkedAsSigninRequiredByAuthor": string;
/**
*
*/
"lockdown": string;
/**
*
*/
"pleaseSelectAccount": string;
"_accountSettings": {
/**
*
*/
"requireSigninToViewContents": string;
/**
*
*/
"requireSigninToViewContentsDescription1": string;
/**
* URLプレビュー(OGP)Webページへの埋め込み
*/
"requireSigninToViewContentsDescription2": string;
/**
*
*/
"requireSigninToViewContentsDescription3": string;
/**
*
*/
"makeNotesFollowersOnlyBefore": string;
/**
*
*/
"makeNotesFollowersOnlyBeforeDescription": string;
/**
*
*/
"makeNotesHiddenBefore": string;
/**
* ()
*/
"makeNotesHiddenBeforeDescription": string;
/**
*
*/
"mayNotEffectForFederatedNotes": string;
/**
*
*/
"notesHavePassedSpecifiedPeriod": string;
/**
*
*/
"notesOlderThanSpecifiedDateAndTime": string;
};
"_abuseUserReport": { "_abuseUserReport": {
/** /**
* *
@ -8382,14 +8452,26 @@ export interface Locale extends ILocale {
* *
*/ */
"callback": string; "callback": string;
/**
*
*/
"accepted": string;
/** /**
* *
*/ */
"denied": string; "denied": string;
/**
*
*/
"scopeUser": string;
/** /**
* *
*/ */
"pleaseLogin": string; "pleaseLogin": string;
/**
* URLに遷移します
*/
"byClickingYouWillBeRedirectedToThisUrl": string;
}; };
"_antennaSources": { "_antennaSources": {
/** /**
@ -9271,7 +9353,7 @@ export interface Locale extends ILocale {
*/ */
"youGotQuote": ParameterizedString<"name">; "youGotQuote": ParameterizedString<"name">;
/** /**
* {name}Renoteしまし * {name}
*/ */
"youRenoted": ParameterizedString<"name">; "youRenoted": ParameterizedString<"name">;
/** /**
@ -9376,7 +9458,7 @@ export interface Locale extends ILocale {
*/ */
"reply": string; "reply": string;
/** /**
* Renote *
*/ */
"renote": string; "renote": string;
/** /**
@ -9434,7 +9516,7 @@ export interface Locale extends ILocale {
*/ */
"reply": string; "reply": string;
/** /**
* Renote *
*/ */
"renote": string; "renote": string;
}; };
@ -10471,6 +10553,28 @@ export interface Locale extends ILocale {
*/ */
"codeGeneratedDescription": string; "codeGeneratedDescription": string;
}; };
"_selfXssPrevention": {
/**
*
*/
"warning": string;
/**
*
*/
"title": string;
/**
*
*/
"description1": string;
/**
* %c今すぐ作業を中止してこのウィンドウを閉じてください
*/
"description2": string;
/**
* {link}
*/
"description3": ParameterizedString<"link">;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -947,6 +947,9 @@ oneHour: "1時間"
oneDay: "1日" oneDay: "1日"
oneWeek: "1週間" oneWeek: "1週間"
oneMonth: "1ヶ月" oneMonth: "1ヶ月"
threeMonths: "3ヶ月"
oneYear: "1年"
threeDays: "3日"
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
rateLimitExceeded: "レート制限を超えました" rateLimitExceeded: "レート制限を超えました"
@ -1293,6 +1296,22 @@ prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。" prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています" yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。" yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています"
lockdown: "ロックダウン"
pleaseSelectAccount: "アカウントを選択してください"
_accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
requireSigninToViewContentsDescription1: "あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。"
requireSigninToViewContentsDescription2: "URLプレビュー(OGP)、Webページへの埋め込み、ートの引用に対応していないサーバーからの表示も不可になります。"
requireSigninToViewContentsDescription3: "リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。"
makeNotesFollowersOnlyBefore: "過去のノートをフォロワーのみ表示可能にする"
makeNotesFollowersOnlyBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。"
makeNotesHiddenBefore: "過去のノートを非公開化する"
makeNotesHiddenBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。"
mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばない場合があります。"
notesHavePassedSpecifiedPeriod: "指定した時間を経過しているノート"
notesOlderThanSpecifiedDateAndTime: "指定した日時より前のノート"
_abuseUserReport: _abuseUserReport:
forward: "転送" forward: "転送"
@ -2199,8 +2218,11 @@ _auth:
permissionAsk: "このアプリは次の権限を要求しています" permissionAsk: "このアプリは次の権限を要求しています"
pleaseGoBack: "アプリケーションに戻ってやっていってください" pleaseGoBack: "アプリケーションに戻ってやっていってください"
callback: "アプリケーションに戻っています" callback: "アプリケーションに戻っています"
accepted: "アクセスを許可しました"
denied: "アクセスを拒否しました" denied: "アクセスを拒否しました"
scopeUser: "以下のユーザーとして操作しています"
pleaseLogin: "アプリケーションにアクセス許可を与えるには、ログインが必要です。" pleaseLogin: "アプリケーションにアクセス許可を与えるには、ログインが必要です。"
byClickingYouWillBeRedirectedToThisUrl: "アクセスを許可すると、自動で以下のURLに遷移します"
_antennaSources: _antennaSources:
all: "全てのノート" all: "全てのノート"
@ -2448,7 +2470,7 @@ _notification:
youGotMention: "{name}からのメンション" youGotMention: "{name}からのメンション"
youGotReply: "{name}からのリプライ" youGotReply: "{name}からのリプライ"
youGotQuote: "{name}による引用" youGotQuote: "{name}による引用"
youRenoted: "{name}がRenoteしました" youRenoted: "{name}がリノートしました"
youWereFollowed: "フォローされました" youWereFollowed: "フォローされました"
youReceivedFollowRequest: "フォローリクエストが来ました" youReceivedFollowRequest: "フォローリクエストが来ました"
yourFollowRequestAccepted: "フォローリクエストが承認されました" yourFollowRequestAccepted: "フォローリクエストが承認されました"
@ -2476,7 +2498,7 @@ _notification:
follow: "フォロー" follow: "フォロー"
mention: "メンション" mention: "メンション"
reply: "リプライ" reply: "リプライ"
renote: "Renote" renote: "リノート"
quote: "引用" quote: "引用"
reaction: "リアクション" reaction: "リアクション"
pollEnded: "アンケートが終了" pollEnded: "アンケートが終了"
@ -2492,7 +2514,7 @@ _notification:
_actions: _actions:
followBack: "フォローバック" followBack: "フォローバック"
reply: "返信" reply: "返信"
renote: "Renote" renote: "リノート"
_deck: _deck:
alwaysShowMainColumn: "常にメインカラムを表示" alwaysShowMainColumn: "常にメインカラムを表示"
@ -2789,3 +2811,10 @@ _embedCodeGen:
generateCode: "埋め込みコードを作成" generateCode: "埋め込みコードを作成"
codeGenerated: "コードが生成されました" codeGenerated: "コードが生成されました"
codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。"
_selfXssPrevention:
warning: "警告"
title: "「この画面に何か貼り付けろ」はすべて詐欺です。"
description1: "ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。"
description2: "貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。"
description3: "詳しくはこちらをご確認ください。 {link}"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.10.1", "version": "2024.10.2-alpha.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SigninRequiredForShowContents1729333924409 {
name = 'SigninRequiredForShowContents1729333924409'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "requireSigninToViewContents" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "requireSigninToViewContents"`);
}
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MakeNotesHiddenBefore1729486255072 {
name = 'MakeNotesHiddenBefore1729486255072'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesFollowersOnlyBefore" integer`);
await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesHiddenBefore" integer`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesHiddenBefore"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesFollowersOnlyBefore"`);
}
}

View file

@ -83,6 +83,9 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
isExplorable: true, isExplorable: true,
isHibernated: false, isHibernated: false,
isDeleted: false, isDeleted: false,
requireSigninToViewContents: false,
makeNotesFollowersOnlyBefore: null,
makeNotesHiddenBefore: null,
emojis: [], emojis: [],
score: 0, score: 0,
host: null, host: null,

View file

@ -495,6 +495,9 @@ export class ApRendererService {
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
_misskey_summary: profile.description, _misskey_summary: profile.description,
_misskey_followedMessage: profile.followedMessage, _misskey_followedMessage: profile.followedMessage,
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
icon: avatar ? this.renderImage(avatar) : null, icon: avatar ? this.renderImage(avatar) : null,
image: banner ? this.renderImage(banner) : null, image: banner ? this.renderImage(banner) : null,
tag, tag,

View file

@ -555,6 +555,9 @@ const extension_context_definition = {
'_misskey_votes': 'misskey:_misskey_votes', '_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary', '_misskey_summary': 'misskey:_misskey_summary',
'_misskey_followedMessage': 'misskey:_misskey_followedMessage', '_misskey_followedMessage': 'misskey:_misskey_followedMessage',
'_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents',
'_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore',
'_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore',
'isCat': 'misskey:isCat', 'isCat': 'misskey:isCat',
// vcard // vcard
vcard: 'http://www.w3.org/2006/vcard/ns#', vcard: 'http://www.w3.org/2006/vcard/ns#',

View file

@ -356,6 +356,9 @@ export class ApPersonService implements OnModuleInit {
tags, tags,
isBot, isBot,
isCat: (person as any).isCat === true, isCat: (person as any).isCat === true,
requireSigninToViewContents: (person as any).requireSigninToViewContents === true,
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
emojis, emojis,
})) as MiRemoteUser; })) as MiRemoteUser;

View file

@ -14,6 +14,9 @@ export interface IObject {
summary?: string; summary?: string;
_misskey_summary?: string; _misskey_summary?: string;
_misskey_followedMessage?: string | null; _misskey_followedMessage?: string | null;
_misskey_requireSigninToViewContents?: boolean;
_misskey_makeNotesFollowersOnlyBefore?: number | null;
_misskey_makeNotesHiddenBefore?: number | null;
published?: string; published?: string;
cc?: ApObject; cc?: ApObject;
to?: ApObject; to?: ApObject;

View file

@ -102,50 +102,80 @@ export class NoteEntityService implements OnModuleInit {
} }
@bindThis @bindThis
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) { private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
// FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある)
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null)
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
packedNote.visibility = 'followers';
}
}
if (meId === packedNote.userId) return;
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
let hide = false; let hide = false;
// visibility が specified かつ自分が指定されていなかったら非表示 if (packedNote.user.requireSigninToViewContents && meId == null) {
if (packedNote.visibility === 'specified') { hide = true;
if (meId == null) { }
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (specified) { if (!hide) {
hide = false; const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
} else { if ((hiddenBefore != null)
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
hide = true;
}
}
// visibility が specified かつ自分が指定されていなかったら非表示
if (!hide) {
if (packedNote.visibility === 'specified') {
if (meId == null) {
hide = true; hide = true;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (!specified) {
hide = true;
}
} }
} }
} }
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (packedNote.visibility === 'followers') { if (!hide) {
if (meId == null) { if (packedNote.visibility === 'followers') {
hide = true; if (meId == null) {
} else if (meId === packedNote.userId) { hide = true;
hide = false; } else if (packedNote.reply && (meId === packedNote.reply.userId)) {
} else if (packedNote.reply && (meId === packedNote.reply.userId)) { // 自分の投稿に対するリプライ
// 自分の投稿に対するリプライ hide = false;
hide = false; } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { // 自分へのメンション
// 自分へのメンション hide = false;
hide = false; } else {
} else { // フォロワーかどうか
// フォロワーかどうか // TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
const isFollowing = await this.followingsRepository.exists({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followeeId: packedNote.userId, followeeId: packedNote.userId,
followerId: meId, followerId: meId,
}, },
}); });
hide = !isFollowing; hide = !isFollowing;
}
} }
} }
@ -157,6 +187,7 @@ export class NoteEntityService implements OnModuleInit {
packedNote.poll = undefined; packedNote.poll = undefined;
packedNote.cw = null; packedNote.cw = null;
packedNote.isHidden = true; packedNote.isHidden = true;
// TODO: hiddenReason みたいなのを提供しても良さそう
} }
} }

View file

@ -490,6 +490,9 @@ export class UserEntityService implements OnModuleInit {
}))) : [], }))) : [],
isBot: user.isBot, isBot: user.isBot,
isCat: user.isCat, isCat: user.isCat,
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name, name: instance.name,
softwareName: instance.softwareName, softwareName: instance.softwareName,

View file

@ -202,6 +202,23 @@ export class MiUser {
}) })
public isHibernated: boolean; public isHibernated: boolean;
@Column('boolean', {
default: false,
})
public requireSigninToViewContents: boolean;
// in sec, マイナスで相対時間
@Column('integer', {
nullable: true,
})
public makeNotesFollowersOnlyBefore: number | null;
// in sec, マイナスで相対時間
@Column('integer', {
nullable: true,
})
public makeNotesHiddenBefore: number | null;
// アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ
@Column('boolean', { @Column('boolean', {
default: false, default: false,

View file

@ -115,6 +115,18 @@ export const packedUserLiteSchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: true, nullable: false, optional: true,
}, },
requireSigninToViewContents: {
type: 'boolean',
nullable: false, optional: true,
},
makeNotesFollowersOnlyBefore: {
type: 'number',
nullable: true, optional: true,
},
makeNotesHiddenBefore: {
type: 'number',
nullable: true, optional: true,
},
instance: { instance: {
type: 'object', type: 'object',
nullable: false, optional: true, nullable: false, optional: true,

View file

@ -329,6 +329,12 @@ export class FileServerService {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
} }
if (!request.headers['user-agent']) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
}
// Create temp file // Create temp file
const file = await this.getStreamAndTypeFromUrl(url); const file = await this.getStreamAndTypeFromUrl(url);
if (file === '404') { if (file === '404') {

View file

@ -39,6 +39,17 @@ export class GetterService {
return note; return note;
} }
@bindThis
public async getNoteWithUser(noteId: MiNote['id']) {
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] });
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
}
return note;
}
/** /**
* Get user for API processing * Get user for API processing
*/ */

View file

@ -179,6 +179,9 @@ export const paramDef = {
autoAcceptFollowed: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' },
noCrawle: { type: 'boolean' }, noCrawle: { type: 'boolean' },
preventAiLearning: { type: 'boolean' }, preventAiLearning: { type: 'boolean' },
requireSigninToViewContents: { type: 'boolean' },
makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true },
makeNotesHiddenBefore: { type: 'integer', nullable: true },
isBot: { type: 'boolean' }, isBot: { type: 'boolean' },
isCat: { type: 'boolean' }, isCat: { type: 'boolean' },
injectFeaturedNote: { type: 'boolean' }, injectFeaturedNote: { type: 'boolean' },
@ -334,6 +337,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning; if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning;
if (typeof ps.requireSigninToViewContents === 'boolean') updates.requireSigninToViewContents = ps.requireSigninToViewContents;
if ((typeof ps.makeNotesFollowersOnlyBefore === 'number') || (ps.makeNotesFollowersOnlyBefore === null)) updates.makeNotesFollowersOnlyBefore = ps.makeNotesFollowersOnlyBefore;
if ((typeof ps.makeNotesHiddenBefore === 'number') || (ps.makeNotesHiddenBefore === null)) updates.makeNotesHiddenBefore = ps.makeNotesHiddenBefore;
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;

View file

@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const policies = await this.roleService.getUserPolicies(me.id); const policies = await this.roleService.getUserPolicies(me.id);
const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({ const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({
id: MoreThan(this.idService.gen(Date.now() - (policies.inviteExpirationTime * 60 * 1000))), id: MoreThan(this.idService.gen(Date.now() - (policies.inviteLimitCycle * 60 * 1000))),
createdById: me.id, createdById: me.id,
}) : null; }) : null;

View file

@ -26,6 +26,12 @@ export const meta = {
code: 'NO_SUCH_NOTE', code: 'NO_SUCH_NOTE',
id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
}, },
signinRequired: {
message: 'Signin required.',
code: 'SIGNIN_REQUIRED',
id: '8e75455b-738c-471d-9f80-62693f33372e',
},
}, },
} as const; } as const;
@ -44,11 +50,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService, private getterService: GetterService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => { const note = await this.getterService.getNoteWithUser(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err; throw err;
}); });
if (note.user!.requireSigninToViewContents && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
return await this.noteEntityService.pack(note, me, { return await this.noteEntityService.pack(note, me, {
detail: true, detail: true,
}); });

View file

@ -42,6 +42,12 @@ export const meta = {
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222', id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222',
}, },
signinRequired: {
message: 'Signin required.',
code: 'SIGNIN_REQUIRED',
id: 'd1588a9e-4b4d-4c07-807f-16f1486577a2',
},
}, },
} as const; } as const;

View file

@ -30,6 +30,7 @@ import type {
EndedPollNotificationQueue, EndedPollNotificationQueue,
InboxQueue, InboxQueue,
ObjectStorageQueue, ObjectStorageQueue,
RelationshipQueue,
SystemQueue, SystemQueue,
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
@ -122,6 +123,7 @@ export class ClientServerService {
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@ -256,6 +258,7 @@ export class ClientServerService {
this.deliverQueue, this.deliverQueue,
this.inboxQueue, this.inboxQueue,
this.dbQueue, this.dbQueue,
this.relationshipQueue,
this.objectStorageQueue, this.objectStorageQueue,
this.userWebhookDeliverQueue, this.userWebhookDeliverQueue,
this.systemWebhookDeliverQueue, this.systemWebhookDeliverQueue,
@ -606,12 +609,15 @@ export class ClientServerService {
fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => { fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => {
vary(reply.raw, 'Accept'); vary(reply.raw, 'Accept');
const note = await this.notesRepository.findOneBy({ const note = await this.notesRepository.findOne({
id: request.params.note, where: {
visibility: In(['public', 'home']), id: request.params.note,
visibility: In(['public', 'home']),
},
relations: ['user'],
}); });
if (note) { if (note && !note.user!.requireSigninToViewContents) {
const _note = await this.noteEntityService.pack(note); const _note = await this.noteEntityService.pack(note);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
reply.header('Cache-Control', 'public, max-age=15'); reply.header('Cache-Control', 'public, max-age=15');

View file

@ -21,6 +21,7 @@ import { url } from '@@/js/config.js';
import { parseEmbedParams } from '@@/js/embed-page.js'; import { parseEmbedParams } from '@@/js/embed-page.js';
import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
import { serverContext } from '@/server-context.js'; import { serverContext } from '@/server-context.js';
import { i18n } from '@/i18n.js';
import type { Theme } from '@/theme.js'; import type { Theme } from '@/theme.js';
@ -127,6 +128,27 @@ window.onunhandledrejection = null;
removeSplash(); removeSplash();
//#region Self-XSS 対策メッセージ
console.log(
`%c${i18n.ts._selfXssPrevention.warning}`,
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.title}`,
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.description1}`,
'font-size: 16px; font-weight: 700;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.description2}`,
'font-size: 16px;',
'font-size: 20px; font-weight: 700; color: #f00;',
);
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
//#endregion
function removeSplash() { function removeSplash() {
const splash = document.getElementById('splash'); const splash = document.getElementById('splash');
if (splash) { if (splash) {

View file

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

View file

@ -5,12 +5,12 @@
import { defineAsyncComponent, reactive, ref } from 'vue'; import { defineAsyncComponent, reactive, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { apiUrl } from '@@/js/config.js';
import type { MenuItem, MenuButton } from '@/types/menu.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import type { MenuItem, MenuButton } from '@/types/menu.js';
import { del, get, set } from '@/scripts/idb-proxy.js'; import { del, get, set } from '@/scripts/idb-proxy.js';
import { apiUrl } from '@@/js/config.js';
import { waiting, popup, popupMenu, success, alert } from '@/os.js'; import { waiting, popup, popupMenu, success, alert } from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
@ -165,7 +165,18 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
}); });
} }
export function updateAccount(accountData: Partial<Account>) { export function updateAccount(accountData: Account) {
if (!$i) return;
for (const key of Object.keys($i)) {
delete $i[key];
}
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
}
miLocalStorage.setItem('account', JSON.stringify($i));
}
export function updateAccountPartial(accountData: Partial<Account>) {
if (!$i) return; if (!$i) return;
for (const [key, value] of Object.entries(accountData)) { for (const [key, value] of Object.entries(accountData)) {
$i[key] = value; $i[key] = value;
@ -224,26 +235,6 @@ export async function openAccountMenu(opts: {
}, ev: MouseEvent) { }, ev: MouseEvent) {
if (!$i) return; if (!$i) return;
function showSigninDialog() {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
addAccount(res.id, res.i);
success();
},
closed: () => dispose(),
});
}
function createAccount() {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: (res: Misskey.entities.SignupResponse) => {
addAccount(res.id, res.token);
switchAccountWithToken(res.token);
},
closed: () => dispose(),
});
}
async function switchAccount(account: Misskey.entities.UserDetailed) { async function switchAccount(account: Misskey.entities.UserDetailed) {
const storedAccounts = await getAccounts(); const storedAccounts = await getAccounts();
const found = storedAccounts.find(x => x.id === account.id); const found = storedAccounts.find(x => x.id === account.id);
@ -312,10 +303,22 @@ export async function openAccountMenu(opts: {
text: i18n.ts.addAccount, text: i18n.ts.addAccount,
children: [{ children: [{
text: i18n.ts.existingAccount, text: i18n.ts.existingAccount,
action: () => { showSigninDialog(); }, action: () => {
getAccountWithSigninDialog().then(res => {
if (res != null) {
success();
}
});
},
}, { }, {
text: i18n.ts.createAccount, text: i18n.ts.createAccount,
action: () => { createAccount(); }, action: () => {
getAccountWithSignupDialog().then(res => {
if (res != null) {
switchAccountWithToken(res.token);
}
});
},
}], }],
}, { }, {
type: 'link', type: 'link',
@ -336,6 +339,40 @@ export async function openAccountMenu(opts: {
}); });
} }
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
await addAccount(res.id, res.i);
resolve({ id: res.id, token: res.i });
},
cancelled: () => {
resolve(null);
},
closed: () => {
dispose();
},
});
});
}
export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: async (res: Misskey.entities.SignupResponse) => {
await addAccount(res.id, res.token);
resolve({ id: res.id, token: res.token });
},
cancelled: () => {
resolve(null);
},
closed: () => {
dispose();
},
});
});
}
if (_DEV_) { if (_DEV_) {
(window as any).$i = $i; (window as any).$i = $i;
} }

View file

@ -11,7 +11,7 @@ import directives from '@/directives/index.js';
import components from '@/components/index.js'; import components from '@/components/index.js';
import { applyTheme } from '@/scripts/theme.js'; import { applyTheme } from '@/scripts/theme.js';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
import { updateI18n } from '@/i18n.js'; import { updateI18n, i18n } from '@/i18n.js';
import { $i, refreshAccount, login } from '@/account.js'; import { $i, refreshAccount, login } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js'; import { defaultStore, ColdDeviceStorage } from '@/store.js';
import { fetchInstance, instance } from '@/instance.js'; import { fetchInstance, instance } from '@/instance.js';
@ -269,6 +269,27 @@ export async function common(createVue: () => App<Element>) {
removeSplash(); removeSplash();
//#region Self-XSS 対策メッセージ
console.log(
`%c${i18n.ts._selfXssPrevention.warning}`,
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.title}`,
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.description1}`,
'font-size: 16px; font-weight: 700;',
);
console.log(
`%c${i18n.ts._selfXssPrevention.description2}`,
'font-size: 16px;',
'font-size: 20px; font-weight: 700; color: #f00;',
);
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
//#endregion
return { return {
isClientUpdated, isClientUpdated,
app, app,

View file

@ -4,14 +4,14 @@
*/ */
import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { createApp, defineAsyncComponent, markRaw } from 'vue';
import { ui } from '@@/js/config.js';
import { common } from './common.js'; import { common } from './common.js';
import type * as Misskey from 'misskey-js'; import type * as Misskey from 'misskey-js';
import { ui } from '@@/js/config.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { alert, confirm, popup, post, toast } from '@/os.js'; import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js'; import { $i, signout, updateAccountPartial } from '@/account.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js'; import { reactionPicker } from '@/scripts/reaction-picker.js';
@ -231,11 +231,41 @@ export async function mainBoot() {
} }
if (!claimedAchievements.includes('justPlainLucky')) { if (!claimedAchievements.includes('justPlainLucky')) {
window.setInterval(() => { let justPlainLuckyTimer: number | null = null;
let lastVisibilityChangedAt = Date.now();
function claimPlainLucky() {
if (document.visibilityState !== 'visible') {
if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer);
return;
}
if (Math.floor(Math.random() * 20000) === 0) { if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky'); claimAchievement('justPlainLucky');
} else {
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
} }
}, 1000 * 10); }
window.addEventListener('visibilitychange', () => {
const now = Date.now();
if (document.visibilityState === 'visible') {
// タブを高速で切り替えたら取得処理が何度も走るのを防ぐ
if ((now - lastVisibilityChangedAt) < 1000 * 10) {
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
} else {
claimPlainLucky();
}
} else if (justPlainLuckyTimer != null) {
window.clearTimeout(justPlainLuckyTimer);
justPlainLuckyTimer = null;
}
lastVisibilityChangedAt = now;
}, { passive: true });
claimPlainLucky();
} }
if (!claimedAchievements.includes('client30min')) { if (!claimedAchievements.includes('client30min')) {
@ -291,11 +321,11 @@ export async function mainBoot() {
// 自分の情報が更新されたとき // 自分の情報が更新されたとき
main.on('meUpdated', i => { main.on('meUpdated', i => {
updateAccount(i); updateAccountPartial(i);
}); });
main.on('readAllNotifications', () => { main.on('readAllNotifications', () => {
updateAccount({ updateAccountPartial({
hasUnreadNotification: false, hasUnreadNotification: false,
unreadNotificationsCount: 0, unreadNotificationsCount: 0,
}); });
@ -303,39 +333,39 @@ export async function mainBoot() {
main.on('unreadNotification', () => { main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateAccount({ updateAccountPartial({
hasUnreadNotification: true, hasUnreadNotification: true,
unreadNotificationsCount, unreadNotificationsCount,
}); });
}); });
main.on('unreadMention', () => { main.on('unreadMention', () => {
updateAccount({ hasUnreadMentions: true }); updateAccountPartial({ hasUnreadMentions: true });
}); });
main.on('readAllUnreadMentions', () => { main.on('readAllUnreadMentions', () => {
updateAccount({ hasUnreadMentions: false }); updateAccountPartial({ hasUnreadMentions: false });
}); });
main.on('unreadSpecifiedNote', () => { main.on('unreadSpecifiedNote', () => {
updateAccount({ hasUnreadSpecifiedNotes: true }); updateAccountPartial({ hasUnreadSpecifiedNotes: true });
}); });
main.on('readAllUnreadSpecifiedNotes', () => { main.on('readAllUnreadSpecifiedNotes', () => {
updateAccount({ hasUnreadSpecifiedNotes: false }); updateAccountPartial({ hasUnreadSpecifiedNotes: false });
}); });
main.on('readAllAntennas', () => { main.on('readAllAntennas', () => {
updateAccount({ hasUnreadAntenna: false }); updateAccountPartial({ hasUnreadAntenna: false });
}); });
main.on('unreadAntenna', () => { main.on('unreadAntenna', () => {
updateAccount({ hasUnreadAntenna: true }); updateAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna'); sound.playMisskeySfx('antenna');
}); });
main.on('readAllAnnouncements', () => { main.on('readAllAnnouncements', () => {
updateAccount({ hasUnreadAnnouncement: false }); updateAccountPartial({ hasUnreadAnnouncement: false });
}); });
// 個人宛てお知らせが発行されたとき // 個人宛てお知らせが発行されたとき

View file

@ -29,7 +29,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import MkModal from '@/components/MkModal.vue'; import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i, updateAccount } from '@/account.js'; import { $i, updateAccountPartial } from '@/account.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
announcement: Misskey.entities.Announcement; announcement: Misskey.entities.Announcement;
@ -51,7 +51,7 @@ async function ok() {
modal.value?.close(); modal.value?.close();
misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); misskeyApi('i/read-announcement', { announcementId: props.announcement.id });
updateAccount({ updateAccountPartial({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id),
}); });
} }

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkAuthConfirm from './MkAuthConfirm.vue';
void MkAuthConfirm;

View file

@ -0,0 +1,450 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_enterActive"
:leaveActiveClass="$style.transition_leaveActive"
:enterFromClass="$style.transition_enterFrom"
:leaveToClass="$style.transition_leaveTo"
:inert="_waiting"
>
<div v-if="phase === 'accountSelect'" key="accountSelect" :class="$style.root" class="_gaps">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-user"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts.pleaseSelectAccount }}</div>
</div>
<div :class="$style.accountSelectorRoot">
<div :class="$style.accountSelectorLabel">{{ i18n.ts.selectAccount }}</div>
<div :class="$style.accountSelectorList">
<template v-for="[id, user] in users">
<input :id="'account-' + id" v-model="selectedUser" type="radio" name="accountSelector" :value="id" :class="$style.accountSelectorRadio"/>
<label :for="'account-' + id" :class="$style.accountSelectorItem">
<MkAvatar :user="user" :class="$style.accountSelectorAvatar"/>
<div :class="$style.accountSelectorBody">
<MkUserName :user="user" :class="$style.accountSelectorName"/>
<MkAcct :user="user" :class="$style.accountSelectorAcct"/>
</div>
</label>
</template>
<button class="_button" :class="[$style.accountSelectorItem, $style.accountSelectorAddAccountRoot]" @click="clickAddAccount">
<div :class="[$style.accountSelectorAvatar, $style.accountSelectorAddAccountAvatar]">
<i class="ti ti-user-plus"></i>
</div>
<div :class="[$style.accountSelectorBody, $style.accountSelectorName]">{{ i18n.ts.addAccount }}</div>
</button>
</div>
</div>
<div class="_buttonsCenter">
<MkButton rounded gradate :disabled="selectedUser === null" @click="clickChooseAccount">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
<div v-else-if="phase === 'consent'" key="consent" :class="$style.root" class="_gaps">
<div :class="$style.header" class="_gaps_s">
<img v-if="icon" :class="$style.icon" :src="getProxiedImageUrl(icon, 'preview')"/>
<div v-else :class="$style.iconFallback">
<i class="ti ti-apps"></i>
</div>
<div :class="$style.headerText">{{ name ? i18n.tsx._auth.shareAccess({ name }) : i18n.ts._auth.shareAccessAsk }}</div>
</div>
<div v-if="permissions && permissions.length > 0" class="_gaps_s" :class="$style.permissionRoot">
<div>{{ name ? i18n.tsx._auth.permission({ name }) : i18n.ts._auth.permissionAsk }}</div>
<div :class="$style.permissionListWrapper">
<ul :class="$style.permissionList">
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
</div>
<slot name="consentAdditionalInfo"></slot>
<div :class="$style.accountSelectorRoot">
<div :class="$style.accountSelectorLabel">
{{ i18n.ts._auth.scopeUser }} <button class="_textButton" @click="clickBackToAccountSelect">{{ i18n.ts.switchAccount }}</button>
</div>
<div :class="$style.accountSelectorList">
<div :class="[$style.accountSelectorItem, $style.static]">
<MkAvatar :user="users.get(selectedUser!)!" :class="$style.accountSelectorAvatar"/>
<div :class="$style.accountSelectorBody">
<MkUserName :user="users.get(selectedUser!)!" :class="$style.accountSelectorName"/>
<MkAcct :user="users.get(selectedUser!)!" :class="$style.accountSelectorAcct"/>
</div>
</div>
</div>
</div>
<div class="_buttonsCenter">
<MkButton rounded @click="clickCancel">{{ i18n.ts.reject }}</MkButton>
<MkButton rounded gradate @click="clickAccept">{{ i18n.ts.accept }}</MkButton>
</div>
</div>
<div v-else-if="phase === 'success'" key="success" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-check"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div>
<div :class="$style.headerTextSub">{{ i18n.ts._auth.pleaseGoBack }}</div>
</div>
</div>
<div v-else-if="phase === 'denied'" key="denied" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-x"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts._auth.denied }}</div>
</div>
</div>
<div v-else-if="phase === 'failed'" key="failed" :class="$style.root" class="_gaps_s">
<div :class="$style.header" class="_gaps_s">
<div :class="$style.iconFallback">
<i class="ti ti-x"></i>
</div>
<div :class="$style.headerText">{{ i18n.ts.somethingHappened }}</div>
</div>
</div>
</Transition>
<div v-if="_waiting" :class="$style.waitingRoot">
<MkLoading/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const props = defineProps<{
name?: string;
icon?: string;
permissions?: (typeof Misskey.permissions[number])[];
manualWaiting?: boolean;
waitOnDeny?: boolean;
}>();
const emit = defineEmits<{
(ev: 'accept', token: string): void;
(ev: 'deny', token: string): void;
}>();
const waiting = ref(true);
const _waiting = computed(() => waiting.value || props.manualWaiting);
const phase = ref<'accountSelect' | 'consent' | 'success' | 'denied' | 'failed'>('accountSelect');
const selectedUser = ref<string | null>(null);
const users = ref(new Map<string, Misskey.entities.UserDetailed & { token: string; }>());
async function init() {
waiting.value = true;
users.value.clear();
if ($i) {
users.value.set($i.id, $i);
}
const accounts = await getAccounts();
const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
if (accountIdsToFetch.length > 0) {
const usersRes = await misskeyApi('users/show', {
userIds: accountIdsToFetch,
});
for (const user of usersRes) {
if (users.value.has(user.id)) continue;
users.value.set(user.id, {
...user,
token: accounts.find(a => a.id === user.id)!.token,
});
}
}
waiting.value = false;
}
init();
function clickAddAccount(ev: MouseEvent) {
selectedUser.value = null;
os.popupMenu([{
text: i18n.ts.existingAccount,
action: () => {
getAccountWithSigninDialog().then(async (res) => {
if (res != null) {
os.success();
await init();
if (users.value.has(res.id)) {
selectedUser.value = res.id;
}
}
});
},
}, {
text: i18n.ts.createAccount,
action: () => {
getAccountWithSignupDialog().then(async (res) => {
if (res != null) {
os.success();
await init();
if (users.value.has(res.id)) {
selectedUser.value = res.id;
}
}
});
},
}], ev.currentTarget ?? ev.target);
}
function clickChooseAccount() {
if (selectedUser.value === null) return;
phase.value = 'consent';
}
function clickBackToAccountSelect() {
selectedUser.value = null;
phase.value = 'accountSelect';
}
function clickCancel() {
if (selectedUser.value === null) return;
const user = users.value.get(selectedUser.value)!;
const token = user.token;
if (props.waitOnDeny) {
waiting.value = true;
}
emit('deny', token);
}
async function clickAccept() {
if (selectedUser.value === null) return;
const user = users.value.get(selectedUser.value)!;
const token = user.token;
waiting.value = true;
emit('accept', token);
}
function showUI(state: 'success' | 'denied' | 'failed') {
phase.value = state;
waiting.value = false;
}
defineExpose({
showUI,
});
</script>
<style lang="scss" module>
.transition_enterActive,
.transition_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.wrapper {
overflow-x: hidden;
overflow-x: clip;
position: relative;
width: 100%;
height: 100%;
}
.waitingRoot {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: color-mix(in srgb, var(--MI_THEME-panel), transparent 50%);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
cursor: wait;
}
.root {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 48px 24px;
}
.header {
margin: 0 auto;
max-width: 320px;
}
.icon,
.iconFallback {
display: block;
margin: 0 auto;
width: 54px;
height: 54px;
}
.icon {
border-radius: 50%;
border: 1px solid var(--MI_THEME-divider);
background-color: #fff;
object-fit: contain;
}
.iconFallback {
border-radius: 50%;
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
text-align: center;
line-height: 54px;
font-size: 18px;
}
.headerText,
.headerTextSub {
text-align: center;
word-break: normal;
word-break: auto-phrase;
}
.headerText {
font-size: 16px;
font-weight: 700;
}
.permissionRoot {
padding: 16px;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-bg);
}
.permissionListWrapper {
max-height: 350px;
overflow-y: auto;
padding: 12px;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
}
.permissionList {
margin: 0 0 0 1.5em;
padding: 0;
font-size: 90%;
}
.accountSelectorLabel {
font-size: 0.85em;
opacity: 0.7;
margin-bottom: 8px;
}
.accountSelectorList {
border-radius: var(--MI-radius);
border: 1px solid var(--MI_THEME-divider);
overflow: hidden;
overflow: clip;
}
.accountSelectorRadio {
position: absolute;
clip: rect(0, 0, 0, 0);
pointer-events: none;
&:focus-visible + .accountSelectorItem {
outline: 2px solid var(--MI_THEME-accent);
outline-offset: -4px;
}
&:checked:focus-visible + .accountSelectorItem {
outline-color: #fff;
}
&:checked + .accountSelectorItem {
background: var(--MI_THEME-accent);
color: #fff;
}
}
.accountSelectorItem {
display: flex;
align-items: center;
padding: 8px;
font-size: 14px;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
&:hover {
background: var(--MI_THEME-buttonHoverBg);
}
&.static {
cursor: unset;
&:hover {
background: none;
}
}
}
.accountSelectorAddAccountRoot {
width: 100%;
}
.accountSelectorBody {
padding: 0 8px;
min-width: 0;
}
.accountSelectorAvatar {
width: 45px;
height: 45px;
}
.accountSelectorAddAccountAvatar {
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
font-size: 16px;
line-height: 45px;
text-align: center;
border-radius: 50%;
}
.accountSelectorName {
display: block;
font-weight: bold;
}
.accountSelectorAcct {
opacity: 0.5;
}
</style>

View file

@ -117,8 +117,8 @@ async function requestRender() {
sitekey: props.sitekey, sitekey: props.sitekey,
theme: defaultStore.state.darkMode ? 'dark' : 'light', theme: defaultStore.state.darkMode ? 'dark' : 'light',
callback: callback, callback: callback,
'expired-callback': callback, 'expired-callback': () => callback(undefined),
'error-callback': callback, 'error-callback': () => callback(undefined),
}); });
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
const { default: Widget } = await import('@mcaptcha/vanilla-glue'); const { default: Widget } = await import('@mcaptcha/vanilla-glue');

View file

@ -64,26 +64,30 @@ const showBody = ref(props.expanded);
const ignoreOmit = ref(false); const ignoreOmit = ref(false);
const omitted = ref(false); const omitted = ref(false);
function enter(el) { function enter(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0; el.style.height = '0';
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px'; el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
} }
function afterEnter(el) { function afterEnter(el: Element) {
el.style.height = null; if (!(el instanceof HTMLElement)) return;
el.style.height = '';
} }
function leave(el) { function leave(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px'; el.style.height = `${elementHeight}px`;
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = 0; el.style.height = '0';
} }
function afterLeave(el) { function afterLeave(el: Element) {
el.style.height = null; if (!(el instanceof HTMLElement)) return;
el.style.height = '';
} }
const calcOmit = () => { const calcOmit = () => {

View file

@ -128,14 +128,14 @@ export default defineComponent({
return children; return children;
}; };
function onBeforeLeave(element: Element) { function onBeforeLeave(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
el.style.top = `${el.offsetTop}px`; el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`; el.style.left = `${el.offsetLeft}px`;
} }
function onLeaveCancelled(element: Element) { function onLeaveCancelled(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
el.style.top = ''; el.style.top = '';
el.style.left = ''; el.style.left = '';
} }

View file

@ -157,7 +157,12 @@ const ilFilesObserver = new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
); );
const sortModeSelect = ref('+createdAt');
watch(folder, () => emit('cd', folder.value)); watch(folder, () => emit('cd', folder.value));
watch(sortModeSelect, () => {
fetch();
});
function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
addFile(file, true); addFile(file, true);
@ -558,6 +563,7 @@ async function fetch() {
folderId: folder.value ? folder.value.id : null, folderId: folder.value ? folder.value.id : null,
type: props.type, type: props.type,
limit: filesMax + 1, limit: filesMax + 1,
sort: sortModeSelect.value,
}).then(fetchedFiles => { }).then(fetchedFiles => {
if (fetchedFiles.length === filesMax + 1) { if (fetchedFiles.length === filesMax + 1) {
moreFiles.value = true; moreFiles.value = true;
@ -607,6 +613,7 @@ function fetchMoreFiles() {
type: props.type, type: props.type,
untilId: files.value.at(-1)?.id, untilId: files.value.at(-1)?.id,
limit: max + 1, limit: max + 1,
sort: sortModeSelect.value,
}).then(files => { }).then(files => {
if (files.length === max + 1) { if (files.length === max + 1) {
moreFiles.value = true; moreFiles.value = true;
@ -642,6 +649,43 @@ function getMenu() {
type: 'label', type: 'label',
}); });
menu.push({
type: 'parent',
text: i18n.ts.sort,
icon: 'ti ti-arrows-sort',
children: [{
text: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`,
icon: 'ti ti-sort-descending-letters',
action: () => { sortModeSelect.value = '+createdAt'; },
active: sortModeSelect.value === '+createdAt',
}, {
text: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`,
icon: 'ti ti-sort-ascending-letters',
action: () => { sortModeSelect.value = '-createdAt'; },
active: sortModeSelect.value === '-createdAt',
}, {
text: `${i18n.ts.size} (${i18n.ts.descendingOrder})`,
icon: 'ti ti-sort-descending-letters',
action: () => { sortModeSelect.value = '+size'; },
active: sortModeSelect.value === '+size',
}, {
text: `${i18n.ts.size} (${i18n.ts.ascendingOrder})`,
icon: 'ti ti-sort-ascending-letters',
action: () => { sortModeSelect.value = '-size'; },
active: sortModeSelect.value === '-size',
}, {
text: `${i18n.ts.name} (${i18n.ts.descendingOrder})`,
icon: 'ti ti-sort-descending-letters',
action: () => { sortModeSelect.value = '+name'; },
active: sortModeSelect.value === '+name',
}, {
text: `${i18n.ts.name} (${i18n.ts.ascendingOrder})`,
icon: 'ti ti-sort-ascending-letters',
action: () => { sortModeSelect.value = '-name'; },
active: sortModeSelect.value === '-name',
}],
});
if (folder.value) { if (folder.value) {
menu.push({ menu.push({
text: i18n.ts.renameFolder, text: i18n.ts.renameFolder,

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div ref="rootEl" :class="$style.root"> <div ref="rootEl" :class="$style.root">
<header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody"> <header :class="$style.header" class="_button" @click="showBody = !showBody">
<div :class="$style.title"><div><slot name="header"></slot></div></div> <div :class="$style.title"><div><slot name="header"></slot></div></div>
<div :class="$style.divider"></div> <div :class="$style.divider"></div>
<button class="_button" :class="$style.button"> <button class="_button" :class="$style.button">
@ -32,21 +32,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, shallowRef, watch } from 'vue'; import { onMounted, ref, shallowRef, watch } from 'vue';
import tinycolor from 'tinycolor2';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { getBgColor } from '@/scripts/get-bg-color.js';
const miLocalStoragePrefix = 'ui:folder:' as const; const miLocalStoragePrefix = 'ui:folder:' as const;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
expanded?: boolean; expanded?: boolean;
persistKey?: string; persistKey?: string | null;
}>(), { }>(), {
expanded: true, expanded: true,
persistKey: null,
}); });
const rootEl = shallowRef<HTMLDivElement>(); const rootEl = shallowRef<HTMLElement>();
const bg = ref<string>(); const parentBg = ref<string | null>(null);
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
watch(showBody, () => { watch(showBody, () => {
@ -55,47 +57,34 @@ watch(showBody, () => {
} }
}); });
function enter(element: Element) { function enter(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = '0'; el.style.height = '0';
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = elementHeight + 'px'; el.style.height = `${elementHeight}px`;
} }
function afterEnter(element: Element) { function afterEnter(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
el.style.height = 'unset'; el.style.height = '';
} }
function leave(element: Element) { function leave(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px'; el.style.height = `${elementHeight}px`;
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = '0'; el.style.height = '0';
} }
function afterLeave(element: Element) { function afterLeave(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
el.style.height = 'unset'; el.style.height = '';
} }
onMounted(() => { onMounted(() => {
function getParentBg(el?: HTMLElement | null): string { parentBg.value = getBgColor(rootEl.value?.parentElement);
if (el == null || el.tagName === 'BODY') return 'var(--MI_THEME-bg)';
const background = el.style.background || el.style.backgroundColor;
if (background) {
return background;
} else {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(rootEl.value);
const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
_bg.setAlpha(0.85);
bg.value = _bg.toRgbString();
}); });
</script> </script>
@ -121,6 +110,7 @@ onMounted(() => {
top: var(--MI-stickyTop, 0px); top: var(--MI-stickyTop, 0px);
-webkit-backdrop-filter: var(--MI-blur, blur(8px)); -webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(20px)); backdrop-filter: var(--MI-blur, blur(20px));
background-color: color(from v-bind("parentBg ?? 'var(--bg)'") srgb r g b / 0.85);
} }
.title { .title {

View file

@ -56,8 +56,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted, shallowRef, ref } from 'vue'; import { nextTick, onMounted, ref, shallowRef } from 'vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { getBgColor } from '@/scripts/get-bg-color.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
defaultOpen?: boolean; defaultOpen?: boolean;
@ -69,40 +70,35 @@ const props = withDefaults(defineProps<{
withSpacer: true, withSpacer: true,
}); });
const getBgColor = (el: HTMLElement) => {
const style = window.getComputedStyle(el);
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
return style.backgroundColor;
} else {
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
}
};
const rootEl = shallowRef<HTMLElement>(); const rootEl = shallowRef<HTMLElement>();
const bgSame = ref(false); const bgSame = ref(false);
const opened = ref(props.defaultOpen); const opened = ref(props.defaultOpen);
const openedAtLeastOnce = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen);
function enter(el) { function enter(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0; el.style.height = '0';
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px'; el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
} }
function afterEnter(el) { function afterEnter(el: Element) {
el.style.height = null; if (!(el instanceof HTMLElement)) return;
el.style.height = '';
} }
function leave(el) { function leave(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height; const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px'; el.style.height = `${elementHeight}px`;
el.offsetHeight; // reflow el.offsetHeight; // reflow
el.style.height = 0; el.style.height = '0';
} }
function afterLeave(el) { function afterLeave(el: Element) {
el.style.height = null; if (!(el instanceof HTMLElement)) return;
el.style.height = '';
} }
function toggle() { function toggle() {
@ -117,7 +113,7 @@ function toggle() {
onMounted(() => { onMounted(() => {
const computedStyle = getComputedStyle(document.documentElement); const computedStyle = getComputedStyle(document.documentElement);
const parentBg = getBgColor(rootEl.value!.parentElement!); const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent';
const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
bgSame.value = parentBg === myBg; bgSame.value = parentBg === myBg;
}); });

View file

@ -37,13 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'; import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
import { host } from '@@/js/config.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -80,7 +80,7 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
} }
async function onClick() { async function onClick() {
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` }); pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } });
wait.value = true; wait.value = true;

View file

@ -26,11 +26,11 @@ import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
import MkModal from './MkModal.vue'; import MkModal from './MkModal.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
withOkButton: boolean; withOkButton?: boolean;
withCloseButton: boolean; withCloseButton?: boolean;
okButtonDisabled: boolean; okButtonDisabled?: boolean;
width: number; width?: number;
height: number; height?: number;
}>(), { }>(), {
withOkButton: false, withOkButton: false,
withCloseButton: true, withCloseButton: true,

View file

@ -227,6 +227,7 @@ const emit = defineEmits<{
}>(); }>();
const inTimeline = inject<boolean>('inTimeline', false); const inTimeline = inject<boolean>('inTimeline', false);
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(false));
const inChannel = inject('inChannel', null); const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
@ -299,7 +300,7 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
if (checkOnly) return false; if (checkOnly) return false;
if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute'; if (inTimeline && !tl_withSensitive.value && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
return false; return false;
} }
@ -419,7 +420,7 @@ if (!props.mock) {
} }
function renote(viaKeyboard = false) { function renote(viaKeyboard = false) {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
@ -429,7 +430,7 @@ function renote(viaKeyboard = false) {
} }
function reply(): void { function reply(): void {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (props.mock) { if (props.mock) {
return; return;
} }
@ -442,7 +443,7 @@ function reply(): void {
} }
function react(): void { function react(): void {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
@ -563,7 +564,7 @@ function showRenoteMenu(): void {
} }
if (isMyRenote) { if (isMyRenote) {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
os.popupMenu([ os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' }, { type: 'divider' },

View file

@ -207,6 +207,7 @@ import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@ -230,7 +231,6 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { host } from '@@/js/config.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
@ -404,7 +404,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
} }
function renote() { function renote() {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton }); const { menu } = getRenoteMenu({ note: note.value, renoteButton });
@ -412,7 +412,7 @@ function renote() {
} }
function reply(): void { function reply(): void {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
os.post({ os.post({
reply: appearNote.value, reply: appearNote.value,
@ -423,7 +423,7 @@ function reply(): void {
} }
function react(): void { function react(): void {
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
@ -499,7 +499,7 @@ async function clip(): Promise<void> {
function showRenoteMenu(): void { function showRenoteMenu(): void {
if (!isMyRenote) return; if (!isMyRenote) return;
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.unrenote, text: i18n.ts.unrenote,
icon: 'ti ti-trash', icon: 'ti ti-trash',

View file

@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { sum } from '@/scripts/array.js'; import { sum } from '@/scripts/array.js';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { host } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
@ -85,7 +85,7 @@ if (props.poll.expiresAt) {
const vote = async (id) => { const vote = async (id) => {
if (props.readOnly || closed.value || isVoted.value) return; if (props.readOnly || closed.value || isVoted.value) return;
pleaseLogin(undefined, pleaseLoginContext.value); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'question', type: 'question',

View file

@ -65,10 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div> </div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
@ -201,6 +201,7 @@ const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'
const imeText = ref(''); const imeText = ref('');
const showingOptions = ref(false); const showingOptions = ref(false);
const textAreaReadOnly = ref(false); const textAreaReadOnly = ref(false);
const justEndedComposition = ref(false);
const draftKey = computed((): string => { const draftKey = computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : ''; let key = props.channel ? `channel:${props.channel.id}` : '';
@ -573,7 +574,13 @@ function clear() {
function onKeydown(ev: KeyboardEvent) { function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post(); if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
if (ev.key === 'Escape') emit('esc'); // justEndedComposition.value is for Safari, which keyDown occurs after compositionend.
// ev.isComposing is for another browsers.
if (ev.key === 'Escape' && !justEndedComposition.value && !ev.isComposing) emit('esc');
}
function onKeyup(ev: KeyboardEvent) {
justEndedComposition.value = false;
} }
function onCompositionUpdate(ev: CompositionEvent) { function onCompositionUpdate(ev: CompositionEvent) {
@ -582,6 +589,7 @@ function onCompositionUpdate(ev: CompositionEvent) {
function onCompositionEnd(ev: CompositionEvent) { function onCompositionEnd(ev: CompositionEvent) {
imeText.value = ''; imeText.value = '';
justEndedComposition.value = true;
} }
async function onPaste(ev: ClipboardEvent) { async function onPaste(ev: ClipboardEvent) {

View file

@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
@keydown.space.enter="show" @keydown.space.enter="show"
> >
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<select <div
ref="inputEl" ref="inputEl"
v-model="v"
v-adaptive-border v-adaptive-border
tabindex="-1" tabindex="-1"
:class="$style.inputCore" :class="$style.inputCore"
@ -26,55 +25,48 @@ SPDX-License-Identifier: AGPL-3.0-only
:required="required" :required="required"
:readonly="readonly" :readonly="readonly"
:placeholder="placeholder" :placeholder="placeholder"
@input="onInput"
@mousedown.prevent="() => {}" @mousedown.prevent="() => {}"
@keydown.prevent="() => {}" @keydown.prevent="() => {}"
> >
<slot></slot> <div style="pointer-events: none;">{{ currentValueText ?? '' }}</div>
</select> <div style="display: none;">
<slot></slot>
</div>
</div>
<div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div> <div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div>
</div> </div>
<div :class="$style.caption"><slot name="caption"></slot></div> <div :class="$style.caption"><slot name="caption"></slot></div>
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { i18n } from '@/i18n.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js';
const props = defineProps<{ const props = defineProps<{
modelValue: string | null; modelValue: string | number | null;
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
inline?: boolean; inline?: boolean;
manualSave?: boolean;
small?: boolean; small?: boolean;
large?: boolean; large?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'changeByUser', value: string | null): void; (ev: 'update:modelValue', value: string | number | null): void;
(ev: 'update:modelValue', value: string | null): void;
}>(); }>();
const slots = useSlots(); const slots = useSlots();
const { modelValue, autofocus } = toRefs(props); const { modelValue, autofocus } = toRefs(props);
const v = ref(modelValue.value);
const focused = ref(false); const focused = ref(false);
const opening = ref(false); const opening = ref(false);
const changed = ref(false); const currentValueText = ref<string | null>(null);
const invalid = ref(false);
const filled = computed(() => v.value !== '' && v.value != null);
const inputEl = ref<HTMLObjectElement | null>(null); const inputEl = ref<HTMLObjectElement | null>(null);
const prefixEl = ref<HTMLElement | null>(null); const prefixEl = ref<HTMLElement | null>(null);
const suffixEl = ref<HTMLElement | null>(null); const suffixEl = ref<HTMLElement | null>(null);
@ -85,26 +77,6 @@ const height =
36; 36;
const focus = () => container.value?.focus(); const focus = () => container.value?.focus();
const onInput = (ev) => {
changed.value = true;
};
const updated = () => {
changed.value = false;
emit('update:modelValue', v.value);
};
watch(modelValue, newValue => {
v.value = newValue;
});
watch(v, () => {
if (!props.manualSave) {
updated();
}
invalid.value = inputEl.value?.validity.badInput ?? true;
});
// //
// 0 // 0
@ -134,6 +106,31 @@ onMounted(() => {
}); });
}); });
watch(modelValue, () => {
const scanOptions = (options: VNodeChild[]) => {
for (const vnode of options) {
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
if (vnode.type === 'optgroup') {
const optgroup = vnode;
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
} else if (Array.isArray(vnode.children)) { //
const fragment = vnode;
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
} else if (vnode.props == null) { // v-if false
// nop?
} else {
const option = vnode;
if (option.props?.value === modelValue.value) {
currentValueText.value = option.children as string;
break;
}
}
}
};
scanOptions(slots.default!());
}, { immediate: true });
function show() { function show() {
if (opening.value) return; if (opening.value) return;
focus(); focus();
@ -146,11 +143,9 @@ function show() {
const pushOption = (option: VNode) => { const pushOption = (option: VNode) => {
menu.push({ menu.push({
text: option.children as string, text: option.children as string,
active: computed(() => v.value === option.props?.value), active: computed(() => modelValue.value === option.props?.value),
action: () => { action: () => {
v.value = option.props?.value; emit('update:modelValue', option.props?.value);
changed.value = true;
emit('changeByUser', v.value);
}, },
}); });
}; };
@ -248,7 +243,8 @@ function show() {
.inputCore { .inputCore {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
display: block; display: flex;
align-items: center;
height: v-bind("height + 'px'"); height: v-bind("height + 'px'");
width: 100%; width: 100%;
margin: 0; margin: 0;

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="dialog" ref="dialog"
:width="500" :width="500"
:height="600" :height="600"
@close="dialog?.close()" @close="onClose"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header>{{ i18n.ts.signup }}</template> <template #header>{{ i18n.ts.signup }}</template>
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="$style.transition_x_leaveTo" :leaveToClass="$style.transition_x_leaveTo"
> >
<template v-if="!isAcceptedServerRule"> <template v-if="!isAcceptedServerRule">
<XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/> <XServerRules @done="isAcceptedServerRule = true" @cancel="onClose"/>
</template> </template>
<template v-else> <template v-else>
<XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', res: Misskey.entities.SignupResponse): void; (ev: 'done', res: Misskey.entities.SignupResponse): void;
(ev: 'cancelled'): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
@ -55,6 +56,11 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const isAcceptedServerRule = ref(false); const isAcceptedServerRule = ref(false);
function onClose() {
emit('cancelled');
dialog.value?.close();
}
function onSignup(res: Misskey.entities.SignupResponse) { function onSignup(res: Misskey.entities.SignupResponse) {
emit('done', res); emit('done', res);
dialog.value?.close(); dialog.value?.close();

View file

@ -38,6 +38,7 @@ const props = withDefaults(defineProps<{
sound?: boolean; sound?: boolean;
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean; withReplies?: boolean;
withSensitive?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
}>(), { }>(), {
withRenotes: true, withRenotes: true,
@ -51,6 +52,7 @@ const emit = defineEmits<{
}>(); }>();
provide('inTimeline', true); provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive));
provide('inChannel', computed(() => props.src === 'channel')); provide('inChannel', computed(() => props.src === 'channel'));
type TimelineQueryType = { type TimelineQueryType = {
@ -248,6 +250,9 @@ function refreshEndpointAndChannel() {
// IDTL // IDTL
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
// withSensitiveOK
watch(() => props.withSensitive, reloadTimeline);
// //
refreshEndpointAndChannel(); refreshEndpointAndChannel();

View file

@ -53,7 +53,7 @@ export type Tab = {
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue'; import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -120,14 +120,14 @@ function onTabWheel(ev: WheelEvent) {
let entering = false; let entering = false;
async function enter(element: Element) { async function enter(el: Element) {
if (!(el instanceof HTMLElement)) return;
entering = true; entering = true;
const el = element as HTMLElement;
const elementWidth = el.getBoundingClientRect().width; const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0'; el.style.width = '0';
el.style.paddingLeft = '0'; el.style.paddingLeft = '0';
el.offsetWidth; // force reflow el.offsetWidth; // reflow
el.style.width = elementWidth + 'px'; el.style.width = `${elementWidth}px`;
el.style.paddingLeft = ''; el.style.paddingLeft = '';
nextTick(() => { nextTick(() => {
entering = false; entering = false;
@ -136,22 +136,23 @@ async function enter(element: Element) {
setTimeout(renderTab, 170); setTimeout(renderTab, 170);
} }
function afterEnter(element: Element) { function afterEnter(el: Element) {
//el.style.width = ''; if (!(el instanceof HTMLElement)) return;
// element.style.width = '';
} }
async function leave(element: Element) { async function leave(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
const elementWidth = el.getBoundingClientRect().width; const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px'; el.style.width = `${elementWidth}px`;
el.style.paddingLeft = ''; el.style.paddingLeft = '';
el.offsetWidth; // force reflow el.offsetWidth; // reflow
el.style.width = '0'; el.style.width = '0';
el.style.paddingLeft = '0'; el.style.paddingLeft = '0';
} }
function afterLeave(element: Element) { function afterLeave(el: Element) {
const el = element as HTMLElement; if (!(el instanceof HTMLElement)) return;
el.style.width = ''; el.style.width = '';
} }

View file

@ -4,19 +4,11 @@
*/ */
import { Directive } from 'vue'; import { Directive } from 'vue';
import { getBgColor } from '@/scripts/get-bg-color.js';
export default { export default {
mounted(src, binding, vn) { mounted(src, binding, vn) {
const getBgColor = (el: HTMLElement) => { const parentBg = getBgColor(src.parentElement) ?? 'transparent';
const style = window.getComputedStyle(el);
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
return style.backgroundColor;
} else {
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
}
};
const parentBg = getBgColor(src.parentElement);
const myBg = window.getComputedStyle(src).backgroundColor; const myBg = window.getComputedStyle(src).backgroundColor;

View file

@ -4,19 +4,11 @@
*/ */
import { Directive } from 'vue'; import { Directive } from 'vue';
import { getBgColor } from '@/scripts/get-bg-color.js';
export default { export default {
mounted(src, binding, vn) { mounted(src, binding, vn) {
const getBgColor = (el: HTMLElement) => { const parentBg = getBgColor(src.parentElement) ?? 'transparent';
const style = window.getComputedStyle(el);
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
return style.backgroundColor;
} else {
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
}
};
const parentBg = getBgColor(src.parentElement);
const myBg = window.getComputedStyle(src).backgroundColor; const myBg = window.getComputedStyle(src).backgroundColor;

View file

@ -4,19 +4,11 @@
*/ */
import { Directive } from 'vue'; import { Directive } from 'vue';
import { getBgColor } from '@/scripts/get-bg-color.js';
export default { export default {
mounted(src, binding, vn) { mounted(src, binding, vn) {
const getBgColor = (el: HTMLElement) => { const parentBg = getBgColor(src.parentElement) ?? 'transparent';
const style = window.getComputedStyle(el);
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
return style.backgroundColor;
} else {
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
}
};
const parentBg = getBgColor(src.parentElement);
const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'); const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel');

View file

@ -688,14 +688,16 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
} }
export function post(props: Record<string, any> = {}): Promise<void> { export function post(props: Record<string, any> = {}): Promise<void> {
pleaseLogin(undefined, (props.initialText || props.initialNote ? { pleaseLogin({
type: 'share', openOnRemote: (props.initialText || props.initialNote ? {
params: { type: 'share',
text: props.initialText ?? props.initialNote.text, params: {
visibility: props.initialVisibility ?? props.initialNote?.visibility, text: props.initialText ?? props.initialNote.text,
localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0', visibility: props.initialVisibility ?? props.initialNote?.visibility,
}, localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0',
} : undefined)); },
} : undefined),
});
showMovedDialog(); showMovedDialog();
return new Promise(resolve => { return new Promise(resolve => {

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s"> <div class="_gaps_s">
<MkInfo v-if="thereIsUnresolvedAbuseReport" warn>{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> <MkInfo v-if="thereIsUnresolvedAbuseReport" warn>{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
<MkInfo v-if="noMaintainerInformation" warn>{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noMaintainerInformation" warn>{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/moderation" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noBotProtection" warn>{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noBotProtection" warn>{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
</div> </div>

View file

@ -55,7 +55,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i, updateAccount } from '@/account.js'; import { $i, updateAccountPartial } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
const props = defineProps<{ const props = defineProps<{
@ -90,7 +90,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> {
target.isRead = true; target.isRead = true;
await misskeyApi('i/read-announcement', { announcementId: target.id }); await misskeyApi('i/read-announcement', { announcementId: target.id });
if ($i) { if ($i) {
updateAccount({ updateAccountPartial({
unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
}); });
} }

View file

@ -56,7 +56,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i, updateAccount } from '@/account.js'; import { $i, updateAccountPartial } from '@/account.js';
const paginationCurrent = { const paginationCurrent = {
endpoint: 'announcements' as const, endpoint: 'announcements' as const,
@ -94,7 +94,7 @@ async function read(target) {
return a; return a;
}); });
misskeyApi('i/read-announcement', { announcementId: target.id }); misskeyApi('i/read-announcement', { announcementId: target.id });
updateAccount({ updateAccountPartial({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
}); });
} }

View file

@ -5,10 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header> <template #header><MkPageHeader/></template>
<MkPageHeader/> <MkSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
</template>
<MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
<div :class="$style.root"> <div :class="$style.root">
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
<div :class="$style.text"> <div :class="$style.text">
@ -16,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.nothing }} {{ i18n.ts.nothing }}
</div> </div>
</div> </div>
</MKSpacer> </MkSpacer>
<MkSpacer v-else :contentMax="800"> <MkSpacer v-else :contentMax="800">
<div class="_gaps_m" style="text-align: center;"> <div class="_gaps_m" style="text-align: center;">
<div v-if="resetCycle && inviteLimit">{{ i18n.tsx.inviteLimitResetCycle({ time: resetCycle, limit: inviteLimit }) }}</div> <div v-if="resetCycle && inviteLimit">{{ i18n.tsx.inviteLimitResetCycle({ time: resetCycle, limit: inviteLimit }) }}</div>

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200"> <MkSpacer v-if="error != null" :contentMax="1200">
<div :class="$style.root"> <div :class="$style.root">
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
<p :class="$style.text"> <p :class="$style.text">
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.nothing }} {{ i18n.ts.nothing }}
</p> </p>
</div> </div>
</MKSpacer> </MkSpacer>
<MkSpacer v-else-if="list" :contentMax="700" :class="$style.main"> <MkSpacer v-else-if="list" :contentMax="700" :class="$style.main">
<div v-if="list" class="members _margin"> <div v-if="list" class="members _margin">
<div :class="$style.member_text">{{ i18n.ts.members }}</div> <div :class="$style.member_text">{{ i18n.ts.members }}</div>
@ -50,7 +50,7 @@ const props = defineProps<{
}>(); }>();
const list = ref<Misskey.entities.UserList | null>(null); const list = ref<Misskey.entities.UserList | null>(null);
const error = ref(); const error = ref<unknown | null>(null);
const users = ref<Misskey.entities.UserDetailed[]>([]); const users = ref<Misskey.entities.UserDetailed[]>([]);
function fetchList(): void { function fetchList(): void {

View file

@ -4,95 +4,79 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkStickyContainer> <div>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkAnimBg style="position: fixed; top: 0;"/>
<MkSpacer :contentMax="800"> <div :class="$style.formContainer">
<div v-if="$i"> <div :class="$style.form">
<div v-if="state == 'waiting'"> <MkAuthConfirm
<MkLoading/> ref="authRoot"
</div> :name="name"
<div v-if="state == 'denied'"> :icon="icon || undefined"
<p>{{ i18n.ts._auth.denied }}</p> :permissions="_permissions"
</div> @accept="onAccept"
<div v-else-if="state == 'accepted'" class="accepted"> @deny="onDeny"
<p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> >
<p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p> <template #consentAdditionalInfo>
</div> <div v-if="callback != null" class="_gaps_s" :class="$style.redirectRoot">
<div v-else> <div>{{ i18n.ts._auth.byClickingYouWillBeRedirectedToThisUrl }}</div>
<div v-if="_permissions.length > 0"> <div class="_monospace" :class="$style.redirectUrl">{{ callback }}</div>
<p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p> </div>
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p> </template>
<ul> </MkAuthConfirm>
<li v-for="p in _permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
</ul>
</div>
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<div :class="$style.buttons">
<MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton>
</div>
</div>
</div> </div>
<div v-else> </div>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> </div>
<MkSignin @login="onLogin"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { computed, useTemplateRef } from 'vue';
import MkSignin from '@/components/MkSignin.vue'; import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js'; import MkAnimBg from '@/components/MkAnimBg.vue';
import { $i, login } from '@/account.js'; import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
const props = defineProps<{ const props = defineProps<{
session: string; session: string;
callback?: string; callback?: string;
name: string; name?: string;
icon: string; icon?: string;
permission: string; // permission?: string; //
}>(); }>();
const _permissions = props.permission ? props.permission.split(',') : []; const _permissions = computed(() => {
return (props.permission ? props.permission.split(',').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) : []);
});
const state = ref<string | null>(null); const authRoot = useTemplateRef('authRoot');
async function accept(): Promise<void> { async function onAccept(token: string) {
state.value = 'waiting';
await misskeyApi('miauth/gen-token', { await misskeyApi('miauth/gen-token', {
session: props.session, session: props.session,
name: props.name, name: props.name,
iconUrl: props.icon, iconUrl: props.icon,
permission: _permissions, permission: _permissions.value,
}, token).catch(() => {
authRoot.value?.showUI('failed');
}); });
state.value = 'accepted'; if (props.callback && props.callback !== '') {
if (props.callback) {
const cbUrl = new URL(props.callback); const cbUrl = new URL(props.callback);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url'); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url');
cbUrl.searchParams.set('session', props.session); cbUrl.searchParams.set('session', props.session);
location.href = cbUrl.href; location.href = cbUrl.toString();
} else {
authRoot.value?.showUI('success');
} }
} }
function deny(): void { function onDeny() {
state.value = 'denied'; authRoot.value?.showUI('denied');
} }
function onLogin(res): void {
login(res.i);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({ definePageMetadata(() => ({
title: 'MiAuth', title: 'MiAuth',
icon: 'ti ti-apps', icon: 'ti ti-apps',
@ -100,15 +84,38 @@ definePageMetadata(() => ({
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.buttons { .formContainer {
margin-top: 16px; min-height: 100svh;
display: flex; padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px;
gap: 8px; box-sizing: border-box;
flex-wrap: wrap; display: grid;
place-content: center;
} }
.loginMessage { .form {
text-align: center; position: relative;
margin: 8px 0 24px; z-index: 10;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
overflow: clip;
max-width: 500px;
width: calc(100vw - 64px);
height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px)));
overflow-y: scroll;
}
.redirectRoot {
padding: 16px;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-bg);
}
.redirectUrl {
font-size: 90%;
padding: 12px;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
overflow-x: scroll;
} }
</style> </style>

View file

@ -24,7 +24,7 @@ const props = defineProps<{
}>(); }>();
if (props.showLoginPopup) { if (props.showLoginPopup) {
pleaseLogin('/'); pleaseLogin({ path: '/' });
} }
const headerActions = computed(() => []); const headerActions = computed(() => []);

View file

@ -61,6 +61,7 @@ import { i18n } from '@/i18n.js';
import { dateString } from '@/filters/date.js'; import { dateString } from '@/filters/date.js';
import MkClipPreview from '@/components/MkClipPreview.vue'; import MkClipPreview from '@/components/MkClipPreview.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { pleaseLogin } from '@/scripts/please-login.js';
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
@ -128,6 +129,11 @@ function fetchNote() {
}); });
} }
}).catch(err => { }).catch(err => {
if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') {
pleaseLogin({
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
});
}
error.value = err; error.value = err;
}); });
} }

View file

@ -4,40 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkStickyContainer> <div>
<template #header><MkPageHeader/></template> <MkAnimBg style="position: fixed; top: 0;"/>
<MkSpacer :contentMax="800"> <div :class="$style.formContainer">
<div v-if="$i"> <div :class="$style.form">
<div v-if="permissions.length > 0"> <MkAuthConfirm
<p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p> ref="authRoot"
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p> :name="name"
<ul> :permissions="permissions"
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> :waitOnDeny="true"
</ul> @accept="onAccept"
</div> @deny="onDeny"
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div> />
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post">
<input name="login_token" type="hidden" :value="$i.token"/>
<input name="transaction_id" type="hidden" :value="transactionIdMeta?.content"/>
<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary>{{ i18n.ts.accept }}</MkButton>
</form>
</div> </div>
<div v-else> </div>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> </div>
<MkSignin @login="onLogin"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import MkSignin from '@/components/MkSignin.vue'; import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue'; import MkAnimBg from '@/components/MkAnimBg.vue';
import { $i, login } from '@/account.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]');
if (transactionIdMeta) { if (transactionIdMeta) {
@ -45,10 +33,44 @@ if (transactionIdMeta) {
} }
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content;
const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? []; const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? [];
function onLogin(res): void { function doPost(token: string, decision: 'accept' | 'deny') {
login(res.i); const form = document.createElement('form');
form.action = '/oauth/decision';
form.method = 'post';
form.acceptCharset = 'utf-8';
const loginToken = document.createElement('input');
loginToken.type = 'hidden';
loginToken.name = 'login_token';
loginToken.value = token;
form.appendChild(loginToken);
const transactionId = document.createElement('input');
transactionId.type = 'hidden';
transactionId.name = 'transaction_id';
transactionId.value = transactionIdMeta?.content ?? '';
form.appendChild(transactionId);
if (decision === 'deny') {
const cancel = document.createElement('input');
cancel.type = 'hidden';
cancel.name = 'cancel';
cancel.value = 'cancel';
form.appendChild(cancel);
}
document.body.appendChild(form);
form.submit();
}
function onAccept(token: string) {
doPost(token, 'accept');
}
function onDeny(token: string) {
doPost(token, 'deny');
} }
definePageMetadata(() => ({ definePageMetadata(() => ({
@ -58,15 +80,24 @@ definePageMetadata(() => ({
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.buttons { .formContainer {
margin-top: 16px; min-height: 100svh;
display: flex; padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px;
gap: 8px; box-sizing: border-box;
flex-wrap: wrap; display: grid;
place-content: center;
} }
.loginMessage { .form {
text-align: center; position: relative;
margin: 8px 0 24px; z-index: 10;
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
overflow: clip;
max-width: 500px;
width: calc(100vw - 64px);
height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px)));
overflow-y: scroll;
} }
</style> </style>

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
<MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200"> <MkSpacer v-if="error != null" :contentMax="1200">
<div :class="$style.root"> <div :class="$style.root">
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
<p :class="$style.text"> <p :class="$style.text">
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ error }} {{ error }}
</p> </p>
</div> </div>
</MKSpacer> </MkSpacer>
<MkSpacer v-else-if="tab === 'users'" :contentMax="1200"> <MkSpacer v-else-if="tab === 'users'" :contentMax="1200">
<div class="_gaps_s"> <div class="_gaps_s">
<div v-if="role">{{ role.description }}</div> <div v-if="role">{{ role.description }}</div>
@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkSpacer> </MkSpacer>
<MkSpacer v-else-if="tab === 'timeline'" :contentMax="700"> <MkSpacer v-else-if="tab === 'timeline'" :contentMax="700">
<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.role"/> <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
<div v-else-if="!visible" class="_fullinfo"> <div v-else-if="!visible" class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/> <img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div> <div>{{ i18n.ts.nothing }}</div>
@ -47,23 +47,24 @@ import { instanceName } from '@@/js/config.js';
import { serverErrorImageUrl, infoImageUrl } from '@/instance.js'; import { serverErrorImageUrl, infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
role: string; roleId: string;
initialTab?: string; initialTab?: string;
}>(), { }>(), {
initialTab: 'users', initialTab: 'users',
}); });
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const tab = ref(props.initialTab); const tab = ref(props.initialTab);
const role = ref<Misskey.entities.Role>(); const role = ref<Misskey.entities.Role | null>(null);
const error = ref(); const error = ref<string | null>(null);
const visible = ref(false); const visible = ref(false);
watch(() => props.role, () => { watch(() => props.roleId, () => {
misskeyApi('roles/show', { misskeyApi('roles/show', {
roleId: props.role, roleId: props.roleId,
}).then(res => { }).then(res => {
role.value = res; role.value = res;
document.title = `${role.value.name} | ${instanceName}`; error.value = null;
visible.value = res.isExplorable && res.isPublic; visible.value = res.isExplorable && res.isPublic;
}).catch((err) => { }).catch((err) => {
if (err.code === 'NO_SUCH_ROLE') { if (err.code === 'NO_SUCH_ROLE') {
@ -71,7 +72,6 @@ watch(() => props.role, () => {
} else { } else {
error.value = i18n.ts.somethingHappened; error.value = i18n.ts.somethingHappened;
} }
document.title = `${error.value} | ${instanceName}`;
}); });
}, { immediate: true }); }, { immediate: true });
@ -79,7 +79,7 @@ const users = computed(() => ({
endpoint: 'roles/users' as const, endpoint: 'roles/users' as const,
limit: 30, limit: 30,
params: { params: {
roleId: props.role, roleId: props.roleId,
}, },
})); }));
@ -94,7 +94,7 @@ const headerTabs = computed(() => [{
}]); }]);
definePageMetadata(() => ({ definePageMetadata(() => ({
title: role.value ? role.value.name : i18n.ts.role, title: role.value ? role.value.name : (error.value ?? i18n.ts.role),
icon: 'ti ti-badge', icon: 'ti ti-badge',
})); }));
</script> </script>

View file

@ -84,7 +84,7 @@ import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkLink from '@/components/MkLink.vue'; import MkLink from '@/components/MkLink.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { signinRequired, updateAccount } from '@/account.js'; import { signinRequired, updateAccountPartial } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const $i = signinRequired(); const $i = signinRequired();
@ -123,7 +123,7 @@ async function unregisterTOTP(): Promise<void> {
password: auth.result.password, password: auth.result.password,
token: auth.result.token, token: auth.result.token,
}).then(res => { }).then(res => {
updateAccount({ updateAccountPartial({
twoFactorEnabled: false, twoFactorEnabled: false,
}); });
}).catch(error => { }).catch(error => {

View file

@ -19,13 +19,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, ref, computed } from 'vue'; import { ref, computed } from 'vue';
import type * as Misskey from 'misskey-js'; import type * as Misskey from 'misskey-js';
import FormSuspense from '@/components/form/suspense.vue'; import FormSuspense from '@/components/form/suspense.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account.js'; import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
@ -74,23 +74,19 @@ async function removeAccount(account: Misskey.entities.UserDetailed) {
} }
function addExistingAccount() { function addExistingAccount() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { getAccountWithSigninDialog().then((res) => {
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { if (res != null) {
await addAccounts(res.id, res.i);
os.success(); os.success();
init(); init();
}, }
closed: () => dispose(),
}); });
} }
function createAccount() { function createAccount() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { getAccountWithSignupDialog().then((res) => {
done: async (res: Misskey.entities.SignupResponse) => { if (res != null) {
await addAccounts(res.id, res.token);
switchAccountWithToken(res.token); switchAccountWithToken(res.token);
}, }
closed: () => dispose(),
}); });
} }

View file

@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps_m"> <div class="_gaps_m">
<MkSelect v-model="type"> <MkSelect v-model="type">
<option value="all">{{ i18n.ts.all }}</option> <option v-for="type in props.configurableTypes ?? notificationConfigTypes" :key="type" :value="type">{{ notificationConfigTypesI18nMap[type] }}</option>
<option value="following">{{ i18n.ts.following }}</option>
<option value="follower">{{ i18n.ts.followers }}</option>
<option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
<option value="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option>
<option value="list">{{ i18n.ts.userList }}</option>
<option value="never">{{ i18n.ts.none }}</option>
</MkSelect> </MkSelect>
<MkSelect v-if="type === 'list'" v-model="userListId"> <MkSelect v-if="type === 'list'" v-model="userListId">
@ -21,31 +15,61 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect> </MkSelect>
<div class="_buttons"> <div class="_buttons">
<MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> <MkButton inline primary :disabled="type === 'list' && userListId === null" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
const notificationConfigTypes = [
'all',
'following',
'follower',
'mutualFollow',
'followingOrFollower',
'list',
'never'
] as const;
export type NotificationConfig = {
type: Exclude<typeof notificationConfigTypes[number], 'list'>;
} | {
type: 'list';
userListId: string;
};
</script>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { ref } from 'vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
value: any; value: NotificationConfig;
userLists: Misskey.entities.UserList[]; userLists: Misskey.entities.UserList[];
configurableTypes?: NotificationConfig['type'][]; // If not specified, all types are configurable
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update', result: any): void; (ev: 'update', result: NotificationConfig): void;
}>(); }>();
const notificationConfigTypesI18nMap: Record<typeof notificationConfigTypes[number], string> = {
all: i18n.ts.all,
following: i18n.ts.following,
follower: i18n.ts.followers,
mutualFollow: i18n.ts.mutualFollow,
followingOrFollower: i18n.ts.followingOrFollower,
list: i18n.ts.userList,
never: i18n.ts.none,
};
const type = ref(props.value.type); const type = ref(props.value.type);
const userListId = ref(props.value.userListId); const userListId = ref(props.value.type === 'list' ? props.value.userListId : null);
function save() { function save() {
emit('update', { type: type.value, userListId: userListId.value }); emit('update', type.value === 'list' ? { type: type.value, userListId: userListId.value! } : { type: type.value });
} }
</script> </script>

View file

@ -22,7 +22,12 @@ SPDX-License-Identifier: AGPL-3.0-only
}} }}
</template> </template>
<XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" @update="(res) => updateReceiveConfig(type, res)"/> <XNotificationConfig
:userLists="userLists"
:value="$i.notificationRecieveConfig[type] ?? { type: 'all' }"
:configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined"
@update="(res) => updateReceiveConfig(type, res)"
/>
</MkFolder> </MkFolder>
</div> </div>
</FormSection> </FormSection>
@ -58,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef, computed } from 'vue'; import { shallowRef, computed } from 'vue';
import XNotificationConfig from './notifications.notification-config.vue'; import XNotificationConfig, { type NotificationConfig } from './notifications.notification-config.vue';
import FormLink from '@/components/form/link.vue'; import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
@ -73,7 +78,9 @@ import { notificationTypes } from '@@/js/const.js';
const $i = signinRequired(); const $i = signinRequired();
const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'achievementEarned', 'test', 'exportCompleted'] as const satisfies (typeof notificationTypes[number])[]; const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[];
const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login'] satisfies (typeof notificationTypes[number])[] as string[];
const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer);
@ -88,7 +95,7 @@ async function readAllNotifications() {
await os.apiWithDialog('notifications/mark-all-as-read'); await os.apiWithDialog('notifications/mark-all-as-read');
} }
async function updateReceiveConfig(type, value) { async function updateReceiveConfig(type: typeof notificationTypes[number], value: NotificationConfig) {
await os.apiWithDialog('i/update', { await os.apiWithDialog('i/update', {
notificationRecieveConfig: { notificationRecieveConfig: {
...$i.notificationRecieveConfig, ...$i.notificationRecieveConfig,

View file

@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.noCrawleDescription }}</template> <template #caption>{{ i18n.ts.noCrawleDescription }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="preventAiLearning" @update:modelValue="save()"> <MkSwitch v-model="preventAiLearning" @update:modelValue="save()">
{{ i18n.ts.preventAiLearning }}<span class="_beta">{{ i18n.ts.beta }}</span> {{ i18n.ts.preventAiLearning }}
<template #caption>{{ i18n.ts.preventAiLearningDescription }}</template> <template #caption>{{ i18n.ts.preventAiLearningDescription }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="isExplorable" @update:modelValue="save()"> <MkSwitch v-model="isExplorable" @update:modelValue="save()">
@ -44,6 +44,93 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.makeExplorableDescription }}</template> <template #caption>{{ i18n.ts.makeExplorableDescription }}</template>
</MkSwitch> </MkSwitch>
<FormSection>
<template #label>{{ i18n.ts.lockdown }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<div class="_gaps_m">
<MkSwitch v-model="requireSigninToViewContents" @update:modelValue="save()">
{{ i18n.ts._accountSettings.requireSigninToViewContents }}
<template #caption>
<div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div>
</template>
</MkSwitch>
<FormSlot>
<template #label>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</template>
<div class="_gaps_s">
<MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null">
<option :value="null">{{ i18n.ts.none }}</option>
<option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option>
<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
</MkSelect>
<MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore">
<option :value="-3600">{{ i18n.ts.oneHour }}</option>
<option :value="-86400">{{ i18n.ts.oneDay }}</option>
<option :value="-259200">{{ i18n.ts.threeDays }}</option>
<option :value="-604800">{{ i18n.ts.oneWeek }}</option>
<option :value="-2592000">{{ i18n.ts.oneMonth }}</option>
<option :value="-7776000">{{ i18n.ts.threeMonths }}</option>
<option :value="-31104000">{{ i18n.ts.oneYear }}</option>
</MkSelect>
<MkInput
v-if="makeNotesFollowersOnlyBefore_type === 'absolute'"
:modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')"
type="date"
:manualSave="true"
@update:modelValue="makeNotesFollowersOnlyBefore = Math.floor(new Date($event).getTime() / 1000)"
>
</MkInput>
</div>
<template #caption>
<div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
</template>
</FormSlot>
<FormSlot>
<template #label>{{ i18n.ts._accountSettings.makeNotesHiddenBefore }}</template>
<div class="_gaps_s">
<MkSelect :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null">
<option :value="null">{{ i18n.ts.none }}</option>
<option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option>
<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
</MkSelect>
<MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore">
<option :value="-3600">{{ i18n.ts.oneHour }}</option>
<option :value="-86400">{{ i18n.ts.oneDay }}</option>
<option :value="-259200">{{ i18n.ts.threeDays }}</option>
<option :value="-604800">{{ i18n.ts.oneWeek }}</option>
<option :value="-2592000">{{ i18n.ts.oneMonth }}</option>
<option :value="-7776000">{{ i18n.ts.threeMonths }}</option>
<option :value="-31104000">{{ i18n.ts.oneYear }}</option>
</MkSelect>
<MkInput
v-if="makeNotesHiddenBefore_type === 'absolute'"
:modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')"
type="date"
:manualSave="true"
@update:modelValue="makeNotesHiddenBefore = Math.floor(new Date($event).getTime() / 1000)"
>
</MkInput>
</div>
<template #caption>
<div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
</template>
</FormSlot>
</div>
</FormSection>
<FormSection> <FormSection>
<div class="_gaps_m"> <div class="_gaps_m">
<MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch> <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch>
@ -72,7 +159,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
@ -82,6 +169,9 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { signinRequired } from '@/account.js'; import { signinRequired } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import FormSlot from '@/components/form/slot.vue';
import { formatDateTimeString } from '@/scripts/format-time-string.js';
import MkInput from '@/components/MkInput.vue';
const $i = signinRequired(); const $i = signinRequired();
@ -90,6 +180,9 @@ const autoAcceptFollowed = ref($i.autoAcceptFollowed);
const noCrawle = ref($i.noCrawle); const noCrawle = ref($i.noCrawle);
const preventAiLearning = ref($i.preventAiLearning); const preventAiLearning = ref($i.preventAiLearning);
const isExplorable = ref($i.isExplorable); const isExplorable = ref($i.isExplorable);
const requireSigninToViewContents = ref($i.requireSigninToViewContents ?? false);
const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null);
const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null);
const hideOnlineStatus = ref($i.hideOnlineStatus); const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions); const publicReactions = ref($i.publicReactions);
const followingVisibility = ref($i.followingVisibility); const followingVisibility = ref($i.followingVisibility);
@ -100,6 +193,30 @@ const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNote
const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility')); const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
const keepCw = computed(defaultStore.makeGetterSetter('keepCw')); const keepCw = computed(defaultStore.makeGetterSetter('keepCw'));
const makeNotesFollowersOnlyBefore_type = computed(() => {
if (makeNotesFollowersOnlyBefore.value == null) {
return null;
} else if (makeNotesFollowersOnlyBefore.value >= 0) {
return 'absolute';
} else {
return 'relative';
}
});
const makeNotesHiddenBefore_type = computed(() => {
if (makeNotesHiddenBefore.value == null) {
return null;
} else if (makeNotesHiddenBefore.value >= 0) {
return 'absolute';
} else {
return 'relative';
}
});
watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => {
save();
});
function save() { function save() {
misskeyApi('i/update', { misskeyApi('i/update', {
isLocked: !!isLocked.value, isLocked: !!isLocked.value,
@ -107,6 +224,9 @@ function save() {
noCrawle: !!noCrawle.value, noCrawle: !!noCrawle.value,
preventAiLearning: !!preventAiLearning.value, preventAiLearning: !!preventAiLearning.value,
isExplorable: !!isExplorable.value, isExplorable: !!isExplorable.value,
requireSigninToViewContents: !!requireSigninToViewContents.value,
makeNotesFollowersOnlyBefore: makeNotesFollowersOnlyBefore.value,
makeNotesHiddenBefore: makeNotesHiddenBefore.value,
hideOnlineStatus: !!hideOnlineStatus.value, hideOnlineStatus: !!hideOnlineStatus.value,
publicReactions: !!publicReactions.value, publicReactions: !!publicReactions.value,
followingVisibility: followingVisibility.value, followingVisibility: followingVisibility.value,

View file

@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:list="src.split(':')[1]" :list="src.split(':')[1]"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies" :withReplies="withReplies"
:withSensitive="withSensitive"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
:sound="true" :sound="true"
@queue="queueUpdated" @queue="queueUpdated"
@ -121,11 +122,6 @@ watch(src, () => {
queue.value = 0; queue.value = 0;
}); });
watch(withSensitive, () => {
//
tlComponent.value?.reloadTimeline();
});
function queueUpdated(q: number): void { function queueUpdated(q: number): void {
queue.value = q; queue.value = q;
} }

View file

@ -217,7 +217,7 @@ const routes: RouteDef[] = [{
component: page(() => import('@/pages/theme-editor.vue')), component: page(() => import('@/pages/theme-editor.vue')),
loginRequired: true, loginRequired: true,
}, { }, {
path: '/roles/:role', path: '/roles/:roleId',
component: page(() => import('@/pages/role.vue')), component: page(() => import('@/pages/role.vue')),
}, { }, {
path: '/user-tags/:tag', path: '/user-tags/:tag',

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import tinycolor from 'tinycolor2';
export const getBgColor = (elem?: Element | null | undefined): string | null => {
if (elem == null) return null;
const { backgroundColor: bg } = window.getComputedStyle(elem);
if (bg && tinycolor(bg).getAlpha() !== 0) {
return bg;
}
return getBgColor(elem.parentElement);
};

View file

@ -44,17 +44,21 @@ export type OpenOnRemoteOptions = {
params: Record<string, string>; params: Record<string, string>;
}; };
export function pleaseLogin(path?: string, openOnRemote?: OpenOnRemoteOptions) { export function pleaseLogin(opts: {
path?: string;
message?: string;
openOnRemote?: OpenOnRemoteOptions;
} = {}) {
if ($i) return; if ($i) return;
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
autoSet: true, autoSet: true,
message: openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired, message: opts.message ?? (opts.openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
openOnRemote, openOnRemote: opts.openOnRemote,
}, { }, {
cancelled: () => { cancelled: () => {
if (path) { if (opts.path) {
window.location.href = path; window.location.href = opts.path;
} }
}, },
closed: () => dispose(), closed: () => dispose(),

View file

@ -49,6 +49,7 @@ export type Column = {
tl?: BasicTimelineType; tl?: BasicTimelineType;
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean; withReplies?: boolean;
withSensitive?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
soundSetting: SoundStore; soundSetting: SoundStore;
}; };

View file

@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:src="column.tl" :src="column.tl"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies" :withReplies="withReplies"
:withSensitive="withSensitive"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
@note="onNote" @note="onNote"
/> />
@ -54,6 +55,7 @@ const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
const withRenotes = ref(props.column.withRenotes ?? true); const withRenotes = ref(props.column.withRenotes ?? true);
const withReplies = ref(props.column.withReplies ?? false); const withReplies = ref(props.column.withReplies ?? false);
const withSensitive = ref(props.column.withSensitive ?? true);
const onlyFiles = ref(props.column.onlyFiles ?? false); const onlyFiles = ref(props.column.onlyFiles ?? false);
watch(withRenotes, v => { watch(withRenotes, v => {
@ -68,6 +70,12 @@ watch(withReplies, v => {
}); });
}); });
watch(withSensitive, v => {
updateColumn(props.column.id, {
withSensitive: v,
});
});
watch(onlyFiles, v => { watch(onlyFiles, v => {
updateColumn(props.column.id, { updateColumn(props.column.id, {
onlyFiles: v, onlyFiles: v,
@ -144,6 +152,10 @@ const menu = computed<MenuItem[]>(() => {
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,
ref: onlyFiles, ref: onlyFiles,
disabled: hasWithReplies(props.column.tl) ? withReplies : false, disabled: hasWithReplies(props.column.tl) ? withReplies : false,
}, {
type: 'switch',
text: i18n.ts.withSensitive,
ref: withSensitive,
}); });
return menuItems; return menuItems;

View file

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2024.10.1", "version": "2024.10.2-alpha.1",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

View file

@ -3736,6 +3736,9 @@ export type components = {
}[]; }[];
isBot?: boolean; isBot?: boolean;
isCat?: boolean; isCat?: boolean;
requireSigninToViewContents?: boolean;
makeNotesFollowersOnlyBefore?: number | null;
makeNotesHiddenBefore?: number | null;
instance?: { instance?: {
name: string | null; name: string | null;
softwareName: string | null; softwareName: string | null;
@ -19844,6 +19847,9 @@ export type operations = {
autoAcceptFollowed?: boolean; autoAcceptFollowed?: boolean;
noCrawle?: boolean; noCrawle?: boolean;
preventAiLearning?: boolean; preventAiLearning?: boolean;
requireSigninToViewContents?: boolean;
makeNotesFollowersOnlyBefore?: number | null;
makeNotesHiddenBefore?: number | null;
isBot?: boolean; isBot?: boolean;
isCat?: boolean; isCat?: boolean;
injectFeaturedNote?: boolean; injectFeaturedNote?: boolean;