Compare commits

...

17 commits

Author SHA1 Message Date
598b88b142 Merge pull request 'Replace notes(userId) index with (userId, id)' (#28) from sugar/forkey:replace-notes-user-id-index-with-user-id-id into main
All checks were successful
Lint / pnpm_install (push) Successful in 31s
Test (production install and build) / production (22.x) (push) Successful in 1m1s
Lint / lint (misskey-js) (push) Successful in 33s
Lint / lint (backend) (push) Successful in 1m17s
Test (backend) / validate-api-json (22.x) (push) Successful in 1m27s
Lint / lint (sw) (push) Successful in 30s
Lint / typecheck (misskey-js) (push) Successful in 31s
Test (backend) / unit (22.x) (push) Successful in 2m50s
Lint / typecheck (backend) (push) Successful in 1m22s
Test (backend) / e2e (22.x) (push) Successful in 6m49s
Lint / lint (frontend) (push) Successful in 7m49s
Reviewed-on: #28
Reviewed-by: leah <leah@noreply.woem.men>
2025-02-23 13:05:54 +00:00
e90e9e016f Merge pull request 'implement mastodon oauth authorization' (#22) from sugar/forkey:implement-mastodon-oauth into main
All checks were successful
Lint / pnpm_install (push) Successful in 33s
API report (misskey.js) / report (push) Successful in 47s
Test (misskey.js) / test (22.x) (push) Successful in 58s
Test (production install and build) / production (22.x) (push) Successful in 1m12s
Lint / lint (misskey-js) (push) Successful in 36s
Test (backend) / validate-api-json (22.x) (push) Successful in 1m28s
Lint / lint (backend) (push) Successful in 1m17s
Lint / lint (sw) (push) Successful in 34s
Test (backend) / unit (22.x) (push) Successful in 2m38s
Lint / typecheck (misskey-js) (push) Successful in 36s
Lint / typecheck (backend) (push) Successful in 1m26s
Test (backend) / e2e (22.x) (push) Successful in 6m37s
Lint / lint (frontend) (push) Successful in 7m38s
Reviewed-on: #22
Reviewed-by: leah <leah@noreply.woem.men>
2025-02-08 22:01:20 +00:00
125bf6fe93 implement mastodon push permission
All checks were successful
Lint / pnpm_install (pull_request) Successful in 30s
API report (misskey.js) / report (pull_request) Successful in 47s
Test (misskey.js) / test (22.x) (pull_request) Successful in 48s
Test (frontend) / vitest (22.x) (pull_request) Successful in 1m19s
Test (backend) / unit (22.x) (pull_request) Successful in 2m44s
Test (production install and build) / production (22.x) (pull_request) Successful in 1m7s
Lint / lint (backend) (pull_request) Successful in 1m7s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 1m24s
Lint / lint (misskey-js) (pull_request) Successful in 36s
Lint / lint (sw) (pull_request) Successful in 32s
Lint / typecheck (misskey-js) (pull_request) Successful in 33s
Lint / typecheck (backend) (pull_request) Successful in 1m24s
Test (backend) / e2e (22.x) (pull_request) Successful in 6m28s
Lint / lint (frontend) (pull_request) Successful in 7m22s
currently doesn't do anything, but it's necessary for masto-fe to be
able to sign in
2025-02-07 21:53:22 +01:00
32fc7644fc add support for form bodies for mastodon apis
this separates registration of mastodon endpoints to another register
scope so that form body parsing wouldn't affect misskey apis
2025-02-07 21:53:22 +01:00
04c1c0e1e6 implement mastodon oauth authorization 2025-02-07 21:42:36 +01:00
cfba325ae7 Merge pull request 'frontend performance improvements' (#59) from sugar/forkey:frontend-performance-improvements into main
All checks were successful
Lint / pnpm_install (push) Successful in 34s
Test (production install and build) / production (22.x) (push) Successful in 1m4s
Lint / lint (misskey-js) (push) Successful in 36s
Lint / lint (sw) (push) Successful in 38s
Lint / lint (backend) (push) Successful in 1m4s
Lint / typecheck (misskey-js) (push) Successful in 35s
Lint / typecheck (backend) (push) Successful in 1m24s
Lint / lint (frontend) (push) Successful in 7m16s
Reviewed-on: #59
Reviewed-by: leah <leah@noreply.woem.men>
2025-02-07 18:36:39 +00:00
ed302618f2 Merge pull request 'Split catspeak and isCat & Accessability' (#48) from feature/speakAsCat into main
All checks were successful
Lint / pnpm_install (push) Successful in 38s
API report (misskey.js) / report (push) Successful in 49s
Test (misskey.js) / test (22.x) (push) Successful in 54s
Test (production install and build) / production (22.x) (push) Successful in 1m8s
Lint / lint (misskey-js) (push) Successful in 36s
Lint / lint (backend) (push) Successful in 1m6s
Test (backend) / validate-api-json (22.x) (push) Successful in 1m30s
Lint / lint (sw) (push) Successful in 31s
Lint / typecheck (misskey-js) (push) Successful in 35s
Test (backend) / unit (22.x) (push) Successful in 2m46s
Lint / typecheck (backend) (push) Successful in 1m29s
Test (backend) / e2e (22.x) (push) Successful in 6m30s
Lint / lint (frontend) (push) Successful in 7m29s
Reviewed-on: #48
2025-02-05 04:22:02 +00:00
12a9dab275 fix tests?
All checks were successful
Lint / pnpm_install (pull_request) Successful in 57s
API report (misskey.js) / report (pull_request) Successful in 1m2s
API report (misskey.js) / report (push) Successful in 1m16s
Test (misskey.js) / test (22.x) (pull_request) Successful in 46s
Test (frontend) / vitest (22.x) (pull_request) Successful in 1m15s
Test (production install and build) / production (22.x) (pull_request) Successful in 1m47s
Lint / lint (backend) (pull_request) Successful in 1m1s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 1m25s
Lint / lint (sw) (pull_request) Successful in 29s
Lint / lint (misskey-js) (pull_request) Successful in 51s
Lint / typecheck (misskey-js) (pull_request) Successful in 31s
Lint / typecheck (backend) (pull_request) Successful in 1m30s
Test (backend) / unit (22.x) (pull_request) Successful in 5m19s
Test (backend) / e2e (22.x) (pull_request) Successful in 6m39s
Lint / lint (frontend) (pull_request) Successful in 8m17s
2025-02-04 14:39:49 +01:00
55a2b82330 Merge branch 'main' into feature/speakAsCat
Some checks failed
Lint / pnpm_install (pull_request) Successful in 27s
API report (misskey.js) / report (push) Successful in 41s
Test (production install and build) / production (22.x) (pull_request) Successful in 1m1s
Test (frontend) / vitest (22.x) (pull_request) Successful in 1m18s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 1m26s
Test (backend) / unit (22.x) (pull_request) Successful in 2m29s
Lint / lint (backend) (pull_request) Successful in 1m1s
Lint / lint (misskey-js) (pull_request) Successful in 32s
Lint / lint (sw) (pull_request) Successful in 29s
Lint / typecheck (misskey-js) (pull_request) Successful in 31s
Lint / typecheck (backend) (pull_request) Failing after 1m19s
Test (backend) / e2e (22.x) (pull_request) Failing after 6m25s
Lint / lint (frontend) (pull_request) Successful in 7m55s
2025-02-04 13:33:11 +00:00
f0de9000a0 fix mix of tabs and spaces
Some checks failed
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 1m43s
Lint / lint (backend) (pull_request) Successful in 1m57s
Test (backend) / unit (22.x) (pull_request) Successful in 3m56s
Lint / lint (frontend) (pull_request) Successful in 9m28s
Lint / lint (misskey-js) (pull_request) Successful in 1m16s
Test (backend) / e2e (22.x) (pull_request) Failing after 8m0s
Lint / lint (sw) (pull_request) Successful in 57s
Lint / typecheck (misskey-js) (pull_request) Successful in 1m15s
Lint / typecheck (backend) (pull_request) Failing after 1m56s
Test (production install and build) / production (22.x) (pull_request) Successful in 4m22s
Lint / pnpm_install (pull_request) Successful in 56s
Test (frontend) / vitest (22.x) (pull_request) Successful in 1m37s
2025-02-03 16:04:12 +01:00
71606c5507 avoid computing tooltip position every frame
All checks were successful
Lint / pnpm_install (pull_request) Successful in 2m59s
Test (frontend) / vitest (22.x) (pull_request) Successful in 2m56s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m16s
Lint / lint (backend) (pull_request) Successful in 2m26s
Lint / lint (frontend) (pull_request) Successful in 9m0s
Lint / lint (misskey-js) (pull_request) Successful in 1m59s
Lint / lint (sw) (pull_request) Successful in 2m13s
Lint / typecheck (backend) (pull_request) Successful in 2m41s
Lint / typecheck (misskey-js) (pull_request) Successful in 1m57s
this fixes an issue causing misskey to constantly recompute tooltip
position when any kind of chart is being displayed (such as the one
on user profile page, or any page for unauthenticated users)
2025-02-02 17:33:33 +01:00
8165088886 only run parallax effect when scrolling user profile
running animation handler every frame is slow
2025-02-02 16:58:14 +01:00
140daecf2f split speak as cat and is cat. Also add option for other people to disable it so they dont have to see cat speak
Some checks failed
Lint / pnpm_install (pull_request) Successful in 2m27s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m22s
Test (production install and build) / production (22.x) (pull_request) Successful in 3m7s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 3m37s
Lint / lint (backend) (pull_request) Failing after 4m30s
Lint / lint (misskey-js) (pull_request) Successful in 4m19s
Lint / lint (frontend) (pull_request) Successful in 9m33s
Lint / lint (sw) (pull_request) Successful in 4m42s
Lint / typecheck (backend) (pull_request) Failing after 3m52s
Lint / typecheck (misskey-js) (pull_request) Successful in 4m54s
Test (backend) / e2e (22.x) (pull_request) Failing after 10m19s
Test (backend) / unit (22.x) (pull_request) Successful in 19m38s
2025-01-19 19:43:39 +01:00
884caa8c1e Merge branch 'main' into replace-notes-user-id-index-with-user-id-id
All checks were successful
Lint / pnpm_install (pull_request) Successful in 3m13s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m17s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m42s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 4m7s
Lint / lint (backend) (pull_request) Successful in 3m11s
Lint / lint (misskey-js) (pull_request) Successful in 2m32s
Lint / lint (sw) (pull_request) Successful in 2m10s
Lint / lint (frontend) (pull_request) Successful in 9m52s
Lint / typecheck (backend) (pull_request) Successful in 3m38s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m57s
Test (backend) / unit (22.x) (pull_request) Successful in 2m44s
Test (backend) / e2e (22.x) (pull_request) Successful in 12m44s
2025-01-19 07:56:10 +00:00
9f4a76a1d9 Merge branch 'main' into replace-notes-user-id-index-with-user-id-id
All checks were successful
Lint / pnpm_install (pull_request) Successful in 2m29s
Test (backend) / unit (22.x) (pull_request) Successful in 8m50s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m38s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m30s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 3m2s
Lint / lint (backend) (pull_request) Successful in 2m58s
Lint / lint (misskey-js) (pull_request) Successful in 2m33s
Lint / lint (sw) (pull_request) Successful in 2m45s
Lint / typecheck (backend) (pull_request) Successful in 3m22s
Lint / lint (frontend) (pull_request) Successful in 9m16s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m40s
Test (backend) / e2e (22.x) (pull_request) Successful in 9m36s
2025-01-19 06:06:19 +00:00
44edae531a Merge branch 'main' into replace-notes-user-id-index-with-user-id-id
Some checks failed
Lint / pnpm_install (pull_request) Successful in 2m44s
Test (backend) / unit (22.x) (pull_request) Successful in 8m16s
Test (backend) / e2e (22.x) (pull_request) Successful in 9m44s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m46s
Test (production install and build) / production (22.x) (pull_request) Successful in 3m5s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 5m21s
Lint / lint (backend) (pull_request) Successful in 4m9s
Lint / lint (misskey-js) (pull_request) Successful in 3m59s
Lint / lint (frontend) (pull_request) Failing after 9m38s
Lint / lint (sw) (pull_request) Successful in 4m49s
Lint / typecheck (backend) (pull_request) Successful in 3m41s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m59s
2025-01-18 20:38:54 -08:00
93dba136ab Replace notes(userId) index with (userId, id)
Some checks failed
Lint / pnpm_install (pull_request) Successful in 2m14s
Test (backend) / unit (22.x) (pull_request) Successful in 7m8s
Test (backend) / e2e (22.x) (pull_request) Failing after 9m26s
Test (frontend) / vitest (22.x) (pull_request) Successful in 3m0s
Test (production install and build) / production (22.x) (pull_request) Successful in 2m37s
Test (backend) / validate-api-json (22.x) (pull_request) Successful in 3m16s
Lint / lint (backend) (pull_request) Successful in 3m1s
Lint / lint (frontend) (pull_request) Successful in 9m53s
Lint / lint (misskey-js) (pull_request) Successful in 2m22s
Lint / lint (sw) (pull_request) Successful in 3m24s
Lint / typecheck (backend) (pull_request) Successful in 3m17s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m22s
This improves performance of queries searching posts by a given user,
optionally with a provided range of dates, and then using ORDER BY to
sort posts by date.

Examples of such queries include: viewing posts by a given user, as
well as checking when the last post by a given user was written for
Mastodon API.
2025-01-17 15:29:08 +00:00
34 changed files with 435 additions and 86 deletions

View file

@ -175,6 +175,8 @@ flagAsBot: "Mark this account as a bot"
flagAsBotDescription: "Enable this option if this account is controlled by a program. If enabled, it will act as a flag for other developers to prevent endless interaction chains with other bots and adjust Misskey's internal systems to treat this account as a bot."
flagAsCat: "Mark this account as a cat"
flagAsCatDescription: "Enable this option to mark this account as a cat."
flagSpeakAsCat: "Speak as a cat"
flagSpeakAsCatDescription: "Your posts will get nyanified when in cat mode. If this isn't working, then please check that you dont have 'Disable cat speak' on under General/Note Display"
flagShowTimelineReplies: "Show replies in timeline"
flagShowTimelineRepliesDescription: "Shows replies of users to notes of other users in the timeline if turned on."
autoAcceptFollowed: "Automatically approve follow requests from users you're following"
@ -762,6 +764,7 @@ noCrawleDescription: "Ask search engines to not index your profile page, notes,
lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", your notes will be visible to anyone, even if you require followers to be manually approved."
alwaysMarkSensitive: "Mark as sensitive by default"
loadRawImages: "Load original images instead of showing thumbnails"
disableCatSpeak: "Disable cat speak"
disableShowingAnimatedImages: "Don't play animated images"
highlightSensitiveMedia: "Highlight sensitive media"
verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification."
@ -2240,6 +2243,7 @@ _permissions:
"read:clip-favorite": "View favorited clips"
"read:federation": "Get federation data"
"write:report-abuse": "Report violation"
"write:push-notification": "Receive push notifications"
_auth:
shareAccessTitle: "Granting application permissions"
shareAccess: "Would you like to authorize \"{name}\" to access this account?"

12
locales/index.d.ts vendored
View file

@ -724,6 +724,14 @@ export interface Locale extends ILocale {
*
*/
"flagAsCatDescription": string;
/**
*
*/
"flagSpeakAsCat": string;
/**
* 稿
*/
"flagSpeakAsCatDescription": string;
/**
*
*/
@ -3064,6 +3072,10 @@ export interface Locale extends ILocale {
*
*/
"loadRawImages": string;
/**
*
*/
"disableCatSpeak": string;
/**
*
*/

View file

@ -72,7 +72,7 @@ export function build() {
.reduce((a, [k, v]) => (a[k] = (() => {
const [lang] = k.split('-');
switch (k) {
case 'ja-JP': return v;
case 'ja-JP': return merge(locales['en-US'], v);
case 'ja-KS':
case 'en-US': return merge(locales['ja-JP'], v);
default: return merge(

View file

@ -177,6 +177,8 @@ flagAsBot: "Botとして設定"
flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。"
flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!"
flagAsCatDescription: "にゃにゃにゃ??"
flagSpeakAsCat: "猫語で話す"
flagSpeakAsCatDescription: "有効にすると、あなたの投稿の 「な」を「にゃ」にします。"
flagShowTimelineReplies: "タイムラインにノートへの返信を表示する"
flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。"
autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
@ -762,6 +764,7 @@ noCrawleDescription: "外部の検索エンジンにあなたのユーザーペ
lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。"
alwaysMarkSensitive: "デフォルトでメディアをセンシティブ設定にする"
loadRawImages: "添付画像のサムネイルをオリジナル画質にする"
disableCatSpeak: "猫の話し方を無効にする"
disableShowingAnimatedImages: "アニメーション画像を再生しない"
highlightSensitiveMedia: "メディアがセンシティブであることを分かりやすく表示"
verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。"

View file

@ -0,0 +1,12 @@
export class SpeakAsCat1696386694000 {
name = "SpeakAsCat1696386694000";
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "speakAsCat" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`COMMENT ON COLUMN "user"."speakAsCat" IS 'Whether to speak as a cat if chosen.'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "speakAsCat"`);
}
}

View file

@ -0,0 +1,18 @@
export class MastodonOauth1736599563231 {
name = 'MastodonOauth1736599563231'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "app" ADD "website" character varying(128)`);
await queryRunner.query(`COMMENT ON COLUMN "app"."website" IS 'Application website.'`);
await queryRunner.query(`ALTER TABLE "app" ADD "mastodonScopes" character varying(64) array`);
await queryRunner.query(`COMMENT ON COLUMN "app"."mastodonScopes" IS 'Mastodon app scopes, only set for apps created with Mastodon API.'`);
await queryRunner.query(`ALTER TABLE "app" ADD "redirectUris" character varying(512) array DEFAULT '{}' NOT NULL`);
await queryRunner.query(`COMMENT ON COLUMN "app"."redirectUris" IS 'Redirect URIs.'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "website"`);
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "mastodonScopes"`);
await queryRunner.query(`ALTER TABLE "app" DROP COLUMN "redirectUris"`);
}
}

View file

@ -0,0 +1,11 @@
export class NoteUserIdIdIndex1736888704471 {
name = 'NoteUserIdIdIndex1736888704471'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_note_userId_id" ON "note" ("userId", "id") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_note_userId_id"`);
}
}

View file

@ -518,6 +518,7 @@ export class ApRendererService {
discoverable: user.isExplorable,
publicKey: this.renderKey(user, keypair, '#main-key'),
isCat: user.isCat,
speakAsCat: user.speakAsCat,
attachment: attachment.length ? attachment : undefined,
};

View file

@ -553,6 +553,9 @@ export const CONTEXTS: (string | Context)[] = [
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'isCat': 'misskey:isCat',
// Firefish
firefish: "https://joinfirefish.org/ns#",
speakAsCat: "firefish:speakAsCat",
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
} satisfies Context,

View file

@ -389,6 +389,7 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot,
isCat: (person as any).isCat === true,
speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true,
emojis,
})) as MiRemoteUser;
@ -562,12 +563,13 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot: getApType(object) === 'Service' || getApType(object) === 'Application',
isCat: (person as any).isCat === true,
speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: person.discoverable,
...((policy.canUpdateAvatar || policy.canUpdateBanner) ? await this.resolveAvatarAndBanner(exist, policy.canUpdateAvatar ? person.icon : exist.avatarUrl, policy.canUpdateBanner ? person.image : exist.bannerUrl).catch(() => ({})) : {}),
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving = ((): boolean => {
// 移行先がない→ある

View file

@ -489,6 +489,7 @@ export class UserEntityService implements OnModuleInit {
}))) : [],
isBot: user.isBot,
isCat: user.isCat,
speakAsCat: user.speakAsCat,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,

View file

@ -0,0 +1,42 @@
import { permissions } from 'misskey-js';
const mastodonToMisskeyScopes: Map<string, (typeof permissions)[number][]> = new Map([
['profile', ['read:account']],
['follow', ['read:following', 'write:following', 'read:blocks', 'write:blocks', 'read:mutes', 'write:mutes']],
['push', ['write:push-notification']],
['read:accounts', ['read:account']],
['read:blocks', ['read:blocks']],
['read:bookmarks', ['read:favorites']],
['read:favourites', ['read:reactions']],
['read:filters', ['read:account']],
['read:follows', ['read:following']],
['read:lists', ['read:account']],
['read:mutes', ['read:mutes']],
['read:notifications', ['read:notifications']],
['read:search', []],
['read:statuses', []],
['write:accounts', ['write:account']],
['write:blocks', ['write:blocks']],
['write:bookmarks', ['write:favorites']],
['write:conversations', ['write:notes']],
['write:favourites', ['write:reactions']],
['write:filters', ['write:account']],
['write:follows', ['write:following']],
['write:lists', ['write:account']],
['write:media', ['read:drive', 'write:drive']],
['write:mutes', ['write:mutes']],
['write:notifications', ['write:notifications']],
['write:reports', ['write:report-abuse']],
['write:statuses', ['write:notes']],
]);
function setHighLevelScope(scopeName: string) {
const granularScopes = Array.from(mastodonToMisskeyScopes)
.flatMap(([key, value]) => key.startsWith(scopeName + ':') ? value : []);
mastodonToMisskeyScopes.set(scopeName, Array.from(new Set(granularScopes)));
}
setHighLevelScope('read');
setHighLevelScope('write');
export { mastodonToMisskeyScopes };

View file

@ -63,4 +63,22 @@ export class MiApp {
comment: 'The callbackUrl of the App.',
})
public callbackUrl: string | null;
@Column('varchar', {
length: 128, nullable: true,
comment: 'Application website.',
})
public website: string;
@Column('varchar', {
length: 64, array: true, nullable: true,
comment: 'Mastodon app scopes, only set for apps created with Mastodon API.',
})
public mastodonScopes: string[];
@Column('varchar', {
length: 512, array: true, nullable: true,
comment: 'Redirect URIs.',
})
public redirectUris: string[];
}

View file

@ -15,6 +15,7 @@ import type { MiDriveFile } from './DriveFile.js';
@Index('IDX_NOTE_MENTIONS', { synchronize: false })
@Index('IDX_NOTE_FILE_IDS', { synchronize: false })
@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false })
@Index('IDX_note_userId_id', ['userId', 'id'])
export class MiNote {
@PrimaryColumn(id())
public id: string;

View file

@ -186,6 +186,12 @@ export class MiUser {
})
public isCat: boolean;
@Column('boolean', {
default: false,
comment: 'Whether the User speaks in nya.',
})
public speakAsCat: boolean;
@Column('boolean', {
default: false,
comment: 'Whether the User is the root.',

View file

@ -115,6 +115,10 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: true,
},
speakAsCat: {
type: 'boolean',
nullable: false, optional: true,
},
instance: {
type: 'object',
nullable: false, optional: true,

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyFormbody from '@fastify/formbody';
import { ModuleRef } from '@nestjs/core';
import type { Config } from '@/config.js';
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
@ -63,6 +64,13 @@ export class ApiServerService {
done();
});
fastify.register(this.createMisskeyServer);
fastify.register(this.createMastodonServer);
done();
}
@bindThis
private createMisskeyServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
for (const endpoint of endpoints) {
const ep = {
name: endpoint.name,
@ -106,32 +114,6 @@ export class ApiServerService {
}
}
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
name: endpoint.name,
method: endpoint.method,
meta: endpoint.meta,
params: endpoint.params,
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
});
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
for (const { name, endpoints } of groupedMastodonEndpoints) {
fastify.all<{
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
}>('/' + name, async (request, reply) => {
const ep = endpoints.get(request.method);
if (!ep) {
reply.code(405);
reply.send();
return;
}
await this.apiCallService.handleMastodonRequest(ep, request, reply);
return reply;
});
}
fastify.post<{
Body: {
username: string;
@ -212,4 +194,39 @@ export class ApiServerService {
done();
}
@bindThis
private createMastodonServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.register(fastifyFormbody);
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
name: endpoint.name,
method: endpoint.method,
meta: endpoint.meta,
params: endpoint.params,
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
});
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
for (const { name, endpoints } of groupedMastodonEndpoints) {
fastify.all<{
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
}>('/' + name, async (request, reply) => {
const ep = endpoints.get(request.method);
if (!ep) {
reply.code(405);
reply.send();
return;
}
await this.apiCallService.handleMastodonRequest(ep, request, reply);
return reply;
});
}
done();
}
}

View file

@ -2,8 +2,10 @@ import { Module, Provider } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
import * as mep___apps_v1_post from './mastodon/apps/v1/post.js';
const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup', useClass: mep___accounts_lookup_v1_get.default };
const $apps_v1_post: Provider = { provide: 'mep:POST:v1/apps', useClass: mep___apps_v1_post.default };
@Module({
imports: [
@ -11,9 +13,11 @@ const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup
],
providers: [
$accounts_lookup_v1_get,
$apps_v1_post,
],
exports: [
$accounts_lookup_v1_get,
$apps_v1_post,
],
})
export class MastodonEndpointsModule {}

View file

@ -187,6 +187,7 @@ export const paramDef = {
preventAiLearning: { type: 'boolean' },
isBot: { type: 'boolean' },
isCat: { type: 'boolean' },
speakAsCat: { type: 'boolean' },
injectFeaturedNote: { type: 'boolean' },
receiveAnnouncementEmail: { type: 'boolean' },
alwaysMarkNsfw: { type: 'boolean' },
@ -341,6 +342,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning;
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat;
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') {

View file

@ -1,8 +1,10 @@
import { Schema } from '@/misc/json-schema.js';
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
import * as mep___apps_v1_post from './mastodon/apps/v1/post.js';
const eps = [
['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get],
['POST', 'v1/apps', mep___apps_v1_post],
];
export interface IMastodonEndpointMeta {

View file

@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import { MastodonEndpoint } from '@/server/api/mastodon-endpoint-base.js';
import { MastodonApiError } from '@/server/api/error.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import type { AppsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { mastodonToMisskeyScopes } from '@/misc/mastodon/mastodon-to-misskey-scopes.js';
export const meta = {
requireCredential: false,
} as const;
export const paramDef = {
type: 'object',
properties: {
client_name: { type: 'string', minLength: 1 },
redirect_uri: { type: 'string', minLength: 1 },
redirect_uris: {
anyOf: [
{ type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
{ type: 'string', minLength: 1 },
],
},
scopes: { type: 'string', minLength: 1 },
website: { type: 'string' },
},
anyOf: [
{ required: ['redirect_uri'] },
{ required: ['redirect_uris'] },
],
required: ['client_name'],
} as const;
@Injectable()
export default class extends MastodonEndpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.appsRepository)
private appsRepository: AppsRepository,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const redirectUrlsRawValue = ps.redirect_uris ?? ps.redirect_uri ?? [];
const redirectUris = typeof redirectUrlsRawValue === 'string' ? redirectUrlsRawValue.split('\n') : redirectUrlsRawValue;
const secret = secureRndstr(32);
const mastodonScopes = (ps.scopes ?? 'read').split(' ');
const scopes = mastodonScopes.flatMap(scope => {
const misskeyScopes = mastodonToMisskeyScopes.get(scope);
if (!misskeyScopes) {
throw new MastodonApiError('Scopes doesn\'t match configured on the server.', 400);
}
return misskeyScopes;
});
const clientId = this.idService.gen();
await this.appsRepository.insert({
id: clientId,
userId: me ? me.id : null,
name: ps.client_name,
description: ps.website ?? '',
permission: scopes,
callbackUrl: null,
secret: secret,
website: ps.website,
mastodonScopes: mastodonScopes,
redirectUris: redirectUris,
});
return {
id: clientId,
name: ps.client_name,
website: ps.website,
scopes: mastodonScopes,
redirect_uri: redirectUris.join('\n'),
redirect_uris: redirectUris,
client_id: `mastodon:${clientId}`,
client_secret: secret,
};
});
}
}

View file

@ -34,6 +34,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type {
AccessTokensRepository,
AppsRepository,
IndieAuthClientsRepository,
UserProfilesRepository,
UsersRepository,
@ -45,9 +46,15 @@ import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import { StatusError } from '@/misc/status-error.js';
import { normalizeEmailAddress } from '@/misc/normalize-email-address.js';
import { mastodonToMisskeyScopes } from '@/misc/mastodon/mastodon-to-misskey-scopes.js';
import type { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify';
function extractMastodonAppId(clientId: string): string | null {
const MASTODON_CLIENT_ID_PREFIX = 'mastodon:';
return clientId.startsWith(MASTODON_CLIENT_ID_PREFIX) ? clientId.substring(MASTODON_CLIENT_ID_PREFIX.length) : null;
}
// TODO: Consider migrating to @node-oauth/oauth2-server once
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
// Upstream the various validations and RFC9207 implementation in that case.
@ -258,6 +265,8 @@ export class OAuth2ProviderService {
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.appsRepository)
private appsRepository: AppsRepository,
private idService: IdService,
private cacheService: CacheService,
@ -328,13 +337,25 @@ export class OAuth2ProviderService {
if (body.client_id !== granted.clientId) return;
if (redirectUri !== granted.redirectUri) return;
const mastodonAppId = extractMastodonAppId(granted.clientId);
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
if (!body.code_verifier) return;
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
// For Mastodon API, code verifier isn't necessary (but if code challenge was provided, then it should be verified)
if (!mastodonAppId || granted.codeChallenge || body.code_verifier) {
if (!body.code_verifier) return;
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
}
const accessToken = secureRndstr(128);
const now = new Date();
let scopes = granted.scopes;
let name = granted.clientId;
if (mastodonAppId) {
scopes = [...new Set(granted.scopes.flatMap((scope: string) => mastodonToMisskeyScopes.get(scope)))];
name = (await this.appsRepository.findOneBy({ id: mastodonAppId }))?.name ?? name;
}
// NOTE: we don't have a setup for automatic token expiration
await accessTokensRepository.insert({
id: idService.gen(now.getTime()),
@ -342,8 +363,9 @@ export class OAuth2ProviderService {
userId: granted.userId,
token: accessToken,
hash: accessToken,
name: granted.clientId,
permission: granted.scopes,
name: name,
permission: scopes,
appId: mastodonAppId,
});
grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`);
@ -354,7 +376,7 @@ export class OAuth2ProviderService {
}
granted.grantedToken = accessToken;
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${scopes}]`);
await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify(granted), 'EX', 60 * 5);
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
@ -418,50 +440,75 @@ export class OAuth2ProviderService {
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
const clientUrl = validateClientId(clientID);
let clientInfo: ClientInformation;
const mastodonAppId = extractMastodonAppId(clientID);
if (mastodonAppId) {
const app = await this.appsRepository.findOneBy({ id: mastodonAppId });
if (!app) {
throw new AuthorizationError('unrecognized client id', 'invalid_request');
}
clientInfo = {
id: clientID,
name: app.name,
redirectUris: app.redirectUris,
};
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
}
try {
const scopes = [...new Set(scope)].filter(s => mastodonToMisskeyScopes.has(s));
if (!scopes.length) {
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
}
areq.scope = scopes;
} catch (err) {
return [err as Error, clientInfo, redirectURI];
}
} else {
const clientUrl = validateClientId(clientID);
// https://indieauth.spec.indieweb.org/#client-information-discovery
// "the server may want to resolve the domain name first and avoid fetching the document
// if the IP address is within the loopback range defined by [RFC5735]
// or any other implementation-specific internal IP address."
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
const lookup = await dns.lookup(clientUrl.hostname);
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
// https://indieauth.spec.indieweb.org/#client-information-discovery
// "the server may want to resolve the domain name first and avoid fetching the document
// if the IP address is within the loopback range defined by [RFC5735]
// or any other implementation-specific internal IP address."
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
const lookup = await dns.lookup(clientUrl.hostname);
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
throw new AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
}
}
// Find client information from the database.
const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null;
// Find client information from the remote.
clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
try {
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
if (!scopes.length) {
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
}
areq.scope = scopes;
// Require PKCE parameters. This requirement is skipped for Mastodon clients, as Mastodon API doesn't require it.
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
if (typeof codeChallenge !== 'string') {
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
}
if (codeChallengeMethod !== 'S256') {
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
}
} catch (err) {
return [err as Error, clientInfo, redirectURI];
}
}
// Find client information from the database.
const registeredClientInfo = await this.indieAuthClientsRepository.findOneBy({ id: clientUrl.href }) as ClientInformation | null;
// Find client information from the remote.
const clientInfo = registeredClientInfo ?? await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
// Require the redirect URI to be included in an explicit list, per
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
if (!clientInfo.redirectUris.includes(redirectURI)) {
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
}
try {
const scopes = [...new Set(scope)].filter(s => (<readonly string[]>kinds).includes(s));
if (!scopes.length) {
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
}
areq.scope = scopes;
// Require PKCE parameters.
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
if (typeof codeChallenge !== 'string') {
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
}
if (codeChallengeMethod !== 'S256') {
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
}
} catch (err) {
return [err as Error, clientInfo, redirectURI];
}
return [null, clientInfo, redirectURI];
})().then(args => done(...args), err => done(err));
}) as ValidateFunctionArity2));

View file

@ -40,6 +40,7 @@ describe('ユーザー', () => {
avatarDecorations: user.avatarDecorations,
isBot: user.isBot,
isCat: user.isCat,
speakAsCat: user.speakAsCat,
instance: user.instance,
emojis: user.emojis,
onlineStatus: user.onlineStatus,
@ -311,6 +312,7 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.avatarDecorations, []);
assert.strictEqual(response.isBot, false);
assert.strictEqual(response.isCat, false);
assert.strictEqual(response.speakAsCat, false);
assert.strictEqual(response.instance, undefined);
assert.deepStrictEqual(response.emojis, {});
assert.strictEqual(response.onlineStatus, 'unknown');
@ -446,6 +448,8 @@ describe('ユーザー', () => {
{ parameters: () => ({ isBot: false }) },
{ parameters: () => ({ isCat: true }) },
{ parameters: () => ({ isCat: false }) },
{ parameters: () => ({ speakAsCat: true }) },
{ parameters: () => ({ speakAsCat: false }) },
{ parameters: () => ({ injectFeaturedNote: true }) },
{ parameters: () => ({ injectFeaturedNote: false }) },
{ parameters: () => ({ receiveAnnouncementEmail: true }) },

View file

@ -100,6 +100,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
isBlocking: false,
isBot: false,
isCat: false,
speakAsCat: false,
isFollowed: false,
isFollowing: false,
isLocked: false,

View file

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import * as os from '@/os.js';
import { calcPopupPosition } from '@/scripts/popup-position.js';
import { defaultStore } from '@/store.js';
@ -70,7 +70,7 @@ function setPosition() {
el.value.style.top = data.top + 'px';
}
let loopHandler;
let loopHandler: number | undefined;
onMounted(() => {
nextTick(() => {
@ -81,12 +81,23 @@ onMounted(() => {
loopHandler = window.requestAnimationFrame(loop);
};
loop();
watch(() => props.showing, show => {
if (show) {
if (!loopHandler) {
loop();
}
} else if (loopHandler) {
window.cancelAnimationFrame(loopHandler);
loopHandler = undefined;
}
});
});
});
onUnmounted(() => {
window.cancelAnimationFrame(loopHandler);
if (loopHandler) {
window.cancelAnimationFrame(loopHandler);
}
});
</script>

View file

@ -55,7 +55,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
provide('linkNavigationBehavior', props.linkNavigationBehavior);
const isNote = props.isNote ?? true;
const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
const shouldNyaize = props.nyaize === 'respect' && props.author?.isCat && props.author?.speakAsCat && !defaultStore.state.disableCatSpeak;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (props.text == null || props.text === '') return;
@ -85,7 +85,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
switch (token.type) {
case 'text': {
let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
if (!disableNyaize && shouldNyaize) {
if (!disableNyaize && shouldNyaize) {
text = doNyaize(text);
}

View file

@ -63,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="showReactionsCount">{{ i18n.ts.showReactionsCount }}</MkSwitch>
<MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch>
<MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch>
<MkSwitch v-model="disableCatSpeak">{{ i18n.ts.disableCatSpeak }}</MkSwitch>
<MkRadios v-model="reactionsDisplaySize">
<template #label>{{ i18n.ts.reactionsDisplaySize }}</template>
<option value="small">{{ i18n.ts.small }}</option>
@ -299,6 +300,7 @@ const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
const disableCatSpeak = computed(defaultStore.makeGetterSetter('disableCatSpeak'));
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText'));

View file

@ -78,6 +78,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'warnMissingAltText',
'imageNewTab',
'dataSaver',
'disableCatSpeak',
'disableShowingAnimatedImages',
'emojiStyle',
'disableDrawer',

View file

@ -171,6 +171,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<MkSwitch v-model="profile.isCat">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></MkSwitch>
<MkSwitch v-if="profile.isCat" v-model="profile.speakAsCat">{{ i18n.ts.flagSpeakAsCat }}<template #caption>{{ i18n.ts.flagSpeakAsCatDescription }}</template></MkSwitch>
<MkSwitch v-model="profile.isBot">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></MkSwitch>
</div>
</MkFolder>
@ -220,6 +221,7 @@ const profile = reactive({
lang: $i.lang,
isBot: $i.isBot ?? false,
isCat: $i.isCat ?? false,
speakAsCat: $i.speakAsCat ?? false,
});
watch(() => profile, () => {
@ -304,6 +306,7 @@ function save() {
lang: profile.lang || null,
isBot: !!profile.isBot,
isCat: !!profile.isCat,
speakAsCat: !!profile.speakAsCat,
});
globalEvents.emit('requestClearPageCache');
claimAchievement('profileFilled');

View file

@ -225,7 +225,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkOmit from '@/components/MkOmit.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkButton from '@/components/MkButton.vue';
import { getScrollPosition } from '@/scripts/scroll.js';
import { getScrollContainer } from '@/scripts/scroll.js';
import { getUserMenu } from '@/scripts/get-user-menu.js';
import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js';
@ -282,6 +282,7 @@ const isEditingMemo = ref(false);
const moderationNote = ref(props.user.moderationNote);
const editModerationNote = ref(false);
const movedFromLog = ref<null | {movedFromId:string;}[]>(null);
let scrollEl: null | HTMLElement = null;
watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value });
@ -313,15 +314,19 @@ async function fetchMovedFromLog() {
}
function parallaxLoop() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
requestNextParallaxFrame();
parallax();
}
function requestNextParallaxFrame() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
}
function parallax() {
const banner = bannerEl.value as any;
if (banner == null) return;
const top = getScrollPosition(rootEl.value);
const top = scrollEl?.scrollTop ?? scrollY;
if (top < 0) return;
@ -330,6 +335,23 @@ function parallax() {
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
}
function startParallax() {
(scrollEl ?? window).removeEventListener('scroll', startParallax);
requestNextParallaxFrame();
}
function addScrollEvent() {
(scrollEl ?? window).addEventListener('scroll', startParallax);
}
function cancelParallax() {
if (parallaxAnimationId.value) {
window.cancelAnimationFrame(parallaxAnimationId.value);
parallaxAnimationId.value = null;
addScrollEvent();
}
}
function showMemoTextarea() {
isEditingMemo.value = true;
nextTick(() => {
@ -394,7 +416,10 @@ watch([props.user], () => {
});
onMounted(() => {
window.requestAnimationFrame(parallaxLoop);
scrollEl = getScrollContainer(rootEl.value);
addScrollEvent();
(scrollEl ?? window).addEventListener('scrollend', cancelParallax);
parallax();
narrow.value = rootEl.value!.clientWidth < 1000;
if (props.user.birthday) {
@ -417,11 +442,7 @@ onMounted(() => {
});
});
onUnmounted(() => {
if (parallaxAnimationId.value) {
window.cancelAnimationFrame(parallaxAnimationId.value);
}
});
onUnmounted(cancelParallax);
</script>
<style lang="scss" scoped>

View file

@ -259,6 +259,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: window.matchMedia('(prefers-reduced-motion)').matches,
},
disableCatSpeak: {
where: 'account',
default: false,
},
emojiStyle: {
where: 'device',
default: 'twemoji', // twemoji / fluentEmoji / native

View file

@ -2820,7 +2820,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
function parse(acct: string): Acct;
// @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:push-notification"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -3853,6 +3853,7 @@ export type components = {
}[];
isBot?: boolean;
isCat?: boolean;
speakAsCat?: boolean;
instance?: {
name: string | null;
softwareName: string | null;
@ -4702,7 +4703,7 @@ export type components = {
blockee: components['schemas']['UserDetailedNotMe'];
};
Hashtag: {
/** @example misskey */
/** @example forkey */
tag: string;
mentionedUsersCount: number;
mentionedLocalUsersCount: number;
@ -4885,7 +4886,7 @@ export type components = {
isNotResponding: boolean;
isSuspended: boolean;
isBlocked: boolean;
/** @example misskey */
/** @example forkey */
softwareName: string | null;
softwareVersion: string | null;
/** @example true */
@ -5239,6 +5240,8 @@ export type components = {
enableTurnstile: boolean;
turnstileSiteKey: string | null;
googleAnalyticsId: string | null;
enableFC: boolean;
fcSiteKey: string | null;
swPublickey: string | null;
/** @default /assets/ai.png */
mascotImageUrl: string;
@ -5388,6 +5391,8 @@ export type operations = {
enableTurnstile: boolean;
turnstileSiteKey: string | null;
googleAnalyticsId: string | null;
enableFC: boolean;
fcSiteKey: string | null;
swPublickey: string | null;
/** @default /assets/ai.png */
mascotImageUrl: string | null;
@ -5414,6 +5419,7 @@ export type operations = {
mcaptchaSecretKey: string | null;
recaptchaSecretKey: string | null;
turnstileSecretKey: string | null;
fcSecretKey: string | null;
sensitiveMediaDetection: string;
sensitiveMediaDetectionSensitivity: string;
setSensitiveFlagAutomatically: boolean;
@ -10319,6 +10325,9 @@ export type operations = {
enableTurnstile?: boolean;
turnstileSiteKey?: string | null;
turnstileSecretKey?: string | null;
enableFC?: boolean;
fcSiteKey?: string | null;
fcSecretKey?: string | null;
googleAnalyticsId?: string | null;
/** @enum {string} */
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
@ -21747,6 +21756,7 @@ export type operations = {
preventAiLearning?: boolean;
isBot?: boolean;
isCat?: boolean;
speakAsCat?: boolean;
injectFeaturedNote?: boolean;
receiveAnnouncementEmail?: boolean;
alwaysMarkNsfw?: boolean;
@ -30841,3 +30851,4 @@ export type operations = {
};
};
};

View file

@ -103,6 +103,7 @@ export const permissions = [
'read:clip-favorite',
'read:federation',
'write:report-abuse',
'write:push-notification', // Mastodon permission
] as const;
export const moderationLogTypes = [