Merge branch 'develop' into minify-backend
This commit is contained in:
commit
ff8658fcf9
|
|
@ -54,7 +54,7 @@ body:
|
|||
* Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4
|
||||
* Browser: Chrome 113.0.5672.126
|
||||
* Server URL: misskey.example.com
|
||||
* Misskey: 2025.x.x
|
||||
* Misskey: 2026.x.x
|
||||
value: |
|
||||
* Model and OS of the device(s):
|
||||
* Browser:
|
||||
|
|
@ -74,7 +74,7 @@ body:
|
|||
|
||||
Examples:
|
||||
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
|
||||
* Misskey: 2025.x.x
|
||||
* Misskey: 2026.x.x
|
||||
* Node: 20.x.x
|
||||
* PostgreSQL: 18.x.x
|
||||
* Redis: 7.x.x
|
||||
|
|
|
|||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -1,16 +1,28 @@
|
|||
## Unreleased
|
||||
## 2026.1.0
|
||||
|
||||
### Note
|
||||
- `users/following` の `birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。
|
||||
|
||||
### General
|
||||
-
|
||||
- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey)
|
||||
- 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました
|
||||
- 依存関係の更新
|
||||
|
||||
### Client
|
||||
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
|
||||
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
|
||||
- Enhance: ウィジェットの設定項目のラベルの多言語対応
|
||||
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
|
||||
- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正
|
||||
- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正
|
||||
- Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正
|
||||
- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
- Enhance: OAuthのクライアント情報取得(Client Information Discovery)において、IndieWeb Living Standard 11 July 2024で定義されているJSONドキュメント形式に対応しました
|
||||
- JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります
|
||||
- 従来の実装(12 February 2022版・HTML Microformat形式)も引き続きサポートされます
|
||||
|
||||
|
||||
## 2025.12.2
|
||||
|
|
|
|||
2
COPYING
2
COPYING
|
|
@ -1,5 +1,5 @@
|
|||
Unless otherwise stated this repository is
|
||||
Copyright © 2014-2025 syuilo and contributors
|
||||
Copyright © 2014-2026 syuilo and contributors
|
||||
|
||||
And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE.
|
||||
|
||||
|
|
|
|||
|
|
@ -2600,7 +2600,7 @@ _widgets:
|
|||
_userList:
|
||||
chooseList: "リストを選択"
|
||||
clicker: "クリッカー"
|
||||
birthdayFollowings: "今日誕生日のユーザー"
|
||||
birthdayFollowings: "もうすぐ誕生日のユーザー"
|
||||
chat: "ダイレクトメッセージ"
|
||||
|
||||
_widgetOptions:
|
||||
|
|
@ -2639,6 +2639,8 @@ _widgetOptions:
|
|||
shuffle: "表示順をシャッフル"
|
||||
duration: "ティッカーのスクロール速度(秒)"
|
||||
reverse: "逆方向にスクロール"
|
||||
_birthdayFollowings:
|
||||
period: "期間"
|
||||
|
||||
_cw:
|
||||
hide: "隠す"
|
||||
|
|
|
|||
22
package.json
22
package.json
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.12.2",
|
||||
"version": "2026.1.0-alpha.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"packageManager": "pnpm@10.26.2",
|
||||
"workspaces": [
|
||||
"packages/misskey-js",
|
||||
"packages/i18n",
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"cssnano": "7.1.2",
|
||||
"esbuild": "0.27.1",
|
||||
"esbuild": "0.27.2",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "8.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
|
|
@ -67,19 +67,19 @@
|
|||
"terser": "5.44.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@misskey-dev/eslint-plugin": "2.2.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251206.1",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251226.1",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.7.1",
|
||||
"eslint": "9.39.1",
|
||||
"cypress": "15.8.1",
|
||||
"eslint": "9.39.2",
|
||||
"globals": "16.5.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "10.25.0",
|
||||
"pnpm": "10.26.2",
|
||||
"typescript": "5.9.3",
|
||||
"start-server-and-test": "2.1.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class BirthdayIndex1767169026317 {
|
||||
name = 'BirthdayIndex1767169026317'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
|
||||
await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
|
||||
await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,20 +41,20 @@
|
|||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"@swc/core-darwin-arm64": "1.15.3",
|
||||
"@swc/core-darwin-x64": "1.15.3",
|
||||
"@swc/core-darwin-arm64": "1.15.7",
|
||||
"@swc/core-darwin-x64": "1.15.7",
|
||||
"@swc/core-freebsd-x64": "1.3.11",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.3",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.3",
|
||||
"@swc/core-linux-arm64-musl": "1.15.3",
|
||||
"@swc/core-linux-x64-gnu": "1.15.3",
|
||||
"@swc/core-linux-x64-musl": "1.15.3",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.3",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.3",
|
||||
"@swc/core-win32-x64-msvc": "1.15.3",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.7",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.7",
|
||||
"@swc/core-linux-arm64-musl": "1.15.7",
|
||||
"@swc/core-linux-x64-gnu": "1.15.7",
|
||||
"@swc/core-linux-x64-musl": "1.15.7",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.7",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.7",
|
||||
"@swc/core-win32-x64-msvc": "1.15.7",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-node": "4.22.0",
|
||||
"bufferutil": "4.0.9",
|
||||
"bufferutil": "4.1.0",
|
||||
"slacc-android-arm-eabi": "0.0.10",
|
||||
"slacc-android-arm64": "0.0.10",
|
||||
"slacc-darwin-arm64": "0.0.10",
|
||||
|
|
@ -68,11 +68,11 @@
|
|||
"slacc-linux-x64-musl": "0.0.10",
|
||||
"slacc-win32-arm64-msvc": "0.0.10",
|
||||
"slacc-win32-x64-msvc": "0.0.10",
|
||||
"utf-8-validate": "6.0.5"
|
||||
"utf-8-validate": "6.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.948.0",
|
||||
"@aws-sdk/lib-storage": "3.948.0",
|
||||
"@aws-sdk/client-s3": "3.958.0",
|
||||
"@aws-sdk/lib-storage": "3.958.0",
|
||||
"@discordapp/twemoji": "16.0.1",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.2.0",
|
||||
|
|
@ -83,18 +83,18 @@
|
|||
"@kitajs/html": "4.2.11",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
"@napi-rs/canvas": "0.1.84",
|
||||
"@nestjs/common": "11.1.9",
|
||||
"@nestjs/core": "11.1.9",
|
||||
"@nestjs/testing": "11.1.9",
|
||||
"@napi-rs/canvas": "0.1.87",
|
||||
"@nestjs/common": "11.1.10",
|
||||
"@nestjs/core": "11.1.10",
|
||||
"@nestjs/testing": "11.1.10",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.29.0",
|
||||
"@sentry/profiling-node": "10.29.0",
|
||||
"@sentry/node": "10.32.1",
|
||||
"@sentry/profiling-node": "10.32.1",
|
||||
"@simplewebauthn/server": "13.2.2",
|
||||
"@sinonjs/fake-timers": "15.0.0",
|
||||
"@smithy/node-http-handler": "4.4.5",
|
||||
"@sinonjs/fake-timers": "15.1.0",
|
||||
"@smithy/node-http-handler": "4.4.7",
|
||||
"@swc/cli": "0.7.9",
|
||||
"@swc/core": "1.15.3",
|
||||
"@swc/core": "1.15.7",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@types/redis-info": "3.0.3",
|
||||
"accepts": "1.3.8",
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "2.2.1",
|
||||
"bullmq": "5.65.1",
|
||||
"bullmq": "5.66.3",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"chalk": "5.6.2",
|
||||
"chalk-template": "1.1.2",
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
"fastify": "5.6.2",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "5.1.0",
|
||||
"file-type": "21.1.1",
|
||||
"file-type": "21.2.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.5",
|
||||
"got": "14.6.5",
|
||||
|
|
@ -140,7 +140,7 @@
|
|||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "7.0.1",
|
||||
"nodemailer": "7.0.11",
|
||||
"nodemailer": "7.0.12",
|
||||
"nsfwjs": "4.2.0",
|
||||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.22.3",
|
||||
"re2": "1.23.0",
|
||||
"redis-info": "3.1.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.27.14",
|
||||
"systeminformation": "5.28.1",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.5",
|
||||
"tsc-alias": "1.8.16",
|
||||
|
|
@ -180,8 +180,8 @@
|
|||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@kitajs/ts-html-plugin": "4.1.3",
|
||||
"@nestjs/platform-express": "11.1.9",
|
||||
"@sentry/vue": "10.29.0",
|
||||
"@nestjs/platform-express": "11.1.10",
|
||||
"@sentry/vue": "10.32.1",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.39",
|
||||
"@types/accepts": "1.3.7",
|
||||
|
|
@ -195,11 +195,11 @@
|
|||
"@types/jsonld": "1.5.15",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.15.6",
|
||||
"@types/pg": "8.16.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
|
|
@ -214,8 +214,8 @@
|
|||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cbor": "10.0.11",
|
||||
"cross-env": "10.1.0",
|
||||
|
|
@ -230,6 +230,6 @@
|
|||
"pid-port": "2.0.0",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.1.4",
|
||||
"vite": "7.2.7"
|
||||
"vite": "7.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
me,
|
||||
{
|
||||
...options,
|
||||
userProfile: profilesMap.get(u.id),
|
||||
userProfile: profilesMap?.get(u.id),
|
||||
userRelations: userRelations,
|
||||
userMemos: userMemos,
|
||||
pinNotes: pinNotes,
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js';
|
|||
export * as 'users/flashs' from './endpoints/users/flashs.js';
|
||||
export * as 'users/followers' from './endpoints/users/followers.js';
|
||||
export * as 'users/following' from './endpoints/users/following.js';
|
||||
export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js';
|
||||
export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js';
|
||||
export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js';
|
||||
export * as 'users/lists/create' from './endpoints/users/lists/create.js';
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
birthday: { ...birthdaySchema, nullable: true },
|
||||
birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -146,14 +146,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.andWhere('following.followerId = :userId', { userId: user.id })
|
||||
.innerJoinAndSelect('following.followee', 'followee');
|
||||
|
||||
// @deprecated use get-following-birthday-users instead.
|
||||
if (ps.birthday) {
|
||||
try {
|
||||
const birthday = ps.birthday.substring(5, 10);
|
||||
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
|
||||
birthdayUserQuery.select('user_profile.userId')
|
||||
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
|
||||
query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
|
||||
|
||||
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
|
||||
try {
|
||||
const birthday = ps.birthday.split('-');
|
||||
birthday.shift(); // 年の部分を削除
|
||||
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) });
|
||||
} catch (err) {
|
||||
throw new ApiError(meta.errors.birthdayInvalid);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
FollowingsRepository,
|
||||
UserProfilesRepository,
|
||||
} from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
requireCredential: true,
|
||||
kind: 'read:account',
|
||||
|
||||
description: 'Find users who have a birthday on the specified range.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'misskey:id',
|
||||
},
|
||||
birthday: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'UserLite',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
birthday: {
|
||||
oneOf: [{
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
begin: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
},
|
||||
end: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
},
|
||||
},
|
||||
required: ['begin', 'end'],
|
||||
}],
|
||||
},
|
||||
},
|
||||
required: ['birthday'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.andWhere('following.followerId = :userId', { userId: me.id })
|
||||
.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
|
||||
|
||||
if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) {
|
||||
const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
|
||||
|
||||
// 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換
|
||||
const begin = range.begin.month * 100 + range.begin.day;
|
||||
const end = range.end.month * 100 + range.end.day;
|
||||
|
||||
if (begin <= end) {
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end });
|
||||
} else {
|
||||
// 12/31 から 1/1 の範囲を取得するために OR で対応
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin });
|
||||
qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end });
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const { month, day } = ps.birthday as { month: number; day: number };
|
||||
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day });
|
||||
}
|
||||
|
||||
query.select('following.followeeId', 'user_id');
|
||||
query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date');
|
||||
query.orderBy('birthday_date', 'ASC');
|
||||
|
||||
const birthdayUsers = await query
|
||||
.offset(ps.offset).limit(ps.limit)
|
||||
.getRawMany<{ birthday_date: number; user_id: string }>();
|
||||
|
||||
const users = new Map<string, Packed<'UserLite'>>((
|
||||
await this.userEntityService.packMany(
|
||||
birthdayUsers.map(u => u.user_id),
|
||||
me,
|
||||
{ schema: 'UserLite' },
|
||||
)
|
||||
).map(u => [u.id, u]));
|
||||
|
||||
return birthdayUsers
|
||||
.map(item => {
|
||||
const birthday = new Date();
|
||||
birthday.setHours(0, 0, 0, 0);
|
||||
// item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定
|
||||
birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100);
|
||||
|
||||
if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) {
|
||||
birthday.setFullYear(new Date().getFullYear() + 1);
|
||||
}
|
||||
|
||||
const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`;
|
||||
return {
|
||||
id: item.user_id,
|
||||
birthday: birthdayStr,
|
||||
user: users.get(item.user_id),
|
||||
};
|
||||
})
|
||||
.filter(item => item.user != null)
|
||||
.map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -123,41 +123,84 @@ function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: str
|
|||
return { name, logo };
|
||||
}
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
|
||||
// and if there is an [h-app] with a url property matching the client_id URL,
|
||||
// then it should use the name and icon and display them on the authorization prompt."
|
||||
// (But we don't display any icon for now)
|
||||
// https://indieauth.spec.indieweb.org/#redirect-url
|
||||
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
|
||||
// of redirect_uri at the client_id URL.
|
||||
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
|
||||
// look for an exact match of the given redirect_uri in the request against the list of
|
||||
// redirect_uris discovered after resolving any relative URLs."
|
||||
async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
|
||||
try {
|
||||
const res = await httpRequestService.send(id);
|
||||
const redirectUris: string[] = [];
|
||||
|
||||
const redirectUris: string[] = [];
|
||||
let name = id;
|
||||
let logo: string | null = null;
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#redirect-url
|
||||
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
|
||||
// of redirect_uri at the client_id URL.
|
||||
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
|
||||
// look for an exact match of the given redirect_uri in the request against the list of
|
||||
// redirect_uris discovered after resolving any relative URLs."
|
||||
const linkHeader = res.headers.get('link');
|
||||
if (linkHeader) {
|
||||
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const doc = htmlParser.parse(`<div>${text}</div>`);
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
// Client discovery via JSON document (11 July 2024 spec)
|
||||
// https://indieauth.spec.indieweb.org/#client-metadata
|
||||
// "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing
|
||||
// client metadata defined in [RFC7591], the minimum properties for an IndieAuth
|
||||
// client defined below."
|
||||
|
||||
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
|
||||
const json = await res.json() as {
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
client_uri: string;
|
||||
logo_uri?: string;
|
||||
redirect_uris?: string[];
|
||||
};
|
||||
|
||||
let name = id;
|
||||
let logo: string | null = null;
|
||||
if (text) {
|
||||
const microformats = parseMicroformats(doc, res.url, id);
|
||||
if (typeof microformats.name === 'string') {
|
||||
name = microformats.name;
|
||||
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
|
||||
// "The authorization server MUST verify that the client_id in the document matches the
|
||||
// client_id of the URL where the document was retrieved."
|
||||
if (json.client_id !== id) {
|
||||
throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request');
|
||||
}
|
||||
if (typeof microformats.logo === 'string') {
|
||||
logo = microformats.logo;
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
|
||||
// "The client_uri MUST be a prefix of the client_id."
|
||||
if (!json.client_uri || !id.startsWith(json.client_uri)) {
|
||||
throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request');
|
||||
}
|
||||
|
||||
if (typeof json.client_name === 'string') {
|
||||
name = json.client_name;
|
||||
}
|
||||
|
||||
if (typeof json.logo_uri === 'string') {
|
||||
// Since uri can be relative, resolve it against the document URL
|
||||
logo = new URL(json.logo_uri, res.url).toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(json.redirect_uris)) {
|
||||
redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string'));
|
||||
}
|
||||
} else {
|
||||
// Client discovery via HTML microformats (12 February 2022 spec)
|
||||
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
|
||||
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
|
||||
// and if there is an [h-app] with a url property matching the client_id URL,
|
||||
// then it should use the name and icon and display them on the authorization prompt."
|
||||
const text = await res.text();
|
||||
const doc = htmlParser.parse(`<div>${text}</div>`);
|
||||
|
||||
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
|
||||
|
||||
if (text) {
|
||||
const microformats = parseMicroformats(doc, res.url, id);
|
||||
if (typeof microformats.name === 'string') {
|
||||
name = microformats.name;
|
||||
}
|
||||
if (typeof microformats.logo === 'string') {
|
||||
logo = microformats.logo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +215,8 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
|
|||
logger.error('Error while fetching client information', { err });
|
||||
if (err instanceof StatusError) {
|
||||
throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
|
||||
} else if (err instanceof AuthorizationError) {
|
||||
throw err;
|
||||
} else {
|
||||
throw new AuthorizationError('Failed to parse client information', 'server_error');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`;
|
|||
|
||||
const clientPort = port + 1;
|
||||
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
|
||||
const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`;
|
||||
|
||||
const basicAuthParams: AuthorizationParamsExtended = {
|
||||
redirect_uri,
|
||||
|
|
@ -807,65 +808,19 @@ describe('OAuth', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
describe('Client Information Discovery', () => {
|
||||
describe('Redirection', () => {
|
||||
const tests: Record<string, (reply: FastifyReply) => void> = {
|
||||
'Read HTTP header': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Mixed links': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in Link header': reply => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in HTML': reply => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<link rel="redirect_uri" href="/redirect" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
for (const [title, replyFunc] of Object.entries(tests)) {
|
||||
test(title, async () => {
|
||||
sender = replyFunc;
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
}
|
||||
|
||||
test('No item', async () => {
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
describe('JSON client metadata (11 July 2024)', () => {
|
||||
test('Read JSON document', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
client_name: 'Misklient JSON',
|
||||
logo_uri: '/logo.png',
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
|
@ -877,119 +832,294 @@ describe('OAuth', () => {
|
|||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient JSON');
|
||||
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
|
||||
});
|
||||
|
||||
// direct error because there's no redirect URI to ping
|
||||
test('Merge Link header redirect_uri with JSON redirect_uris', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri"');
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
client_name: 'Misklient JSON',
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const ok1 = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(ok1.status, 200);
|
||||
|
||||
const ok2 = await fetch(client.authorizeURL({
|
||||
redirect_uri: redirect_uri2,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(ok2.status, 200);
|
||||
});
|
||||
|
||||
test('Reject when client_id does not match retrieved URL', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/mismatch`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Reject when client_uri is not a prefix of client_id', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`,
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Reject when JSON metadata has no redirect_uris and no Link header', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
client_name: 'Misklient JSON',
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
});
|
||||
|
||||
test('Disallow loopback', async () => {
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
|
||||
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
|
||||
describe('HTML link client metadata (12 Feb 2022)', () => {
|
||||
describe('Redirection', () => {
|
||||
const tests: Record<string, (reply: FastifyReply) => void> = {
|
||||
'Read HTTP header': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Mixed links': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in Link header': reply => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in HTML': reply => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<link rel="redirect_uri" href="/redirect" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
for (const [title, replyFunc] of Object.entries(tests)) {
|
||||
test(title, async () => {
|
||||
sender = replyFunc;
|
||||
|
||||
test('Missing name', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send();
|
||||
};
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
test('No item', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
};
|
||||
|
||||
test('With Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app">
|
||||
<a href="/" class="u-url p-name">Misklient</a>
|
||||
<img src="/logo.png" class="u-logo" />
|
||||
</div>
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
|
||||
});
|
||||
// direct error because there's no redirect URI to ping
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
});
|
||||
|
||||
test('Missing Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
test('Disallow loopback', async () => {
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, undefined);
|
||||
});
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Mismatching URL in h-app', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
test('Missing name', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
|
||||
test('With Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app">
|
||||
<a href="/" class="u-url p-name">Misklient</a>
|
||||
<img src="/logo.png" class="u-logo" />
|
||||
</div>
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
|
||||
});
|
||||
|
||||
test('Missing Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, undefined);
|
||||
});
|
||||
|
||||
test('Mismatching URL in h-app', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"rollup": "4.53.3"
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"rollup": "4.54.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"estree-walker": "3.0.3",
|
||||
"magic-string": "0.30.21",
|
||||
"vite": "7.2.7"
|
||||
"vite": "7.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.2",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"buraha": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
"frontend-shared": "workspace:*",
|
||||
|
|
@ -25,13 +25,13 @@
|
|||
"mfm-js": "0.25.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.53.3",
|
||||
"sass": "1.95.1",
|
||||
"shiki": "3.19.0",
|
||||
"rollup": "4.54.0",
|
||||
"sass": "1.97.1",
|
||||
"shiki": "3.20.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"uuid": "13.0.0",
|
||||
"vite": "7.2.7",
|
||||
"vue": "3.5.25"
|
||||
"vite": "7.3.0",
|
||||
"vue": "3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
|
|
@ -39,14 +39,14 @@
|
|||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"@vue/runtime-core": "3.5.25",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vue/runtime-core": "3.5.26",
|
||||
"acorn": "8.15.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
|
|
@ -54,13 +54,13 @@
|
|||
"happy-dom": "20.0.11",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.12.4",
|
||||
"msw": "2.12.6",
|
||||
"nodemon": "3.1.11",
|
||||
"prettier": "3.7.4",
|
||||
"start-server-and-test": "2.1.3",
|
||||
"tsx": "4.21.0",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.1.8",
|
||||
"vue-component-type-helpers": "3.2.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.8"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@
|
|||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"esbuild": "0.27.1",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint-plugin-vue": "10.6.2",
|
||||
"nodemon": "3.1.11",
|
||||
"vue-eslint-parser": "10.2.0"
|
||||
|
|
@ -35,6 +35,6 @@
|
|||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"vue": "3.5.25"
|
||||
"vue": "3.5.26"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@
|
|||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@sentry/vue": "10.29.0",
|
||||
"@syuilo/aiscript": "1.2.0",
|
||||
"@sentry/vue": "10.32.1",
|
||||
"@syuilo/aiscript": "1.2.1",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.2",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
|
||||
"analytics": "0.8.19",
|
||||
"broadcast-channel": "7.2.0",
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.25.8",
|
||||
"mediabunny": "1.27.2",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
|
|
@ -64,59 +64,59 @@
|
|||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"rollup": "4.53.3",
|
||||
"rollup": "4.54.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.95.1",
|
||||
"shiki": "3.19.0",
|
||||
"sass": "1.97.1",
|
||||
"shiki": "3.20.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.181.2",
|
||||
"three": "0.182.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "7.2.7",
|
||||
"vue": "3.5.25",
|
||||
"vite": "7.3.0",
|
||||
"vue": "3.5.26",
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "10.1.5",
|
||||
"@storybook/addon-mdx-gfm": "8.6.14",
|
||||
"@storybook/addon-storysource": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/components": "8.6.14",
|
||||
"@storybook/core-events": "8.6.14",
|
||||
"@storybook/manager-api": "8.6.14",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "10.1.5",
|
||||
"@storybook/react-vite": "10.1.5",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@storybook/theming": "8.6.14",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@storybook/vue3": "10.1.5",
|
||||
"@storybook/vue3-vite": "10.1.5",
|
||||
"@storybook/addon-essentials": "8.6.15",
|
||||
"@storybook/addon-interactions": "8.6.15",
|
||||
"@storybook/addon-links": "10.1.10",
|
||||
"@storybook/addon-mdx-gfm": "8.6.15",
|
||||
"@storybook/addon-storysource": "8.6.15",
|
||||
"@storybook/blocks": "8.6.15",
|
||||
"@storybook/components": "8.6.15",
|
||||
"@storybook/core-events": "8.6.15",
|
||||
"@storybook/manager-api": "8.6.15",
|
||||
"@storybook/preview-api": "8.6.15",
|
||||
"@storybook/react": "10.1.10",
|
||||
"@storybook/react-vite": "10.1.10",
|
||||
"@storybook/test": "8.6.15",
|
||||
"@storybook/theming": "8.6.15",
|
||||
"@storybook/types": "8.6.15",
|
||||
"@storybook/vue3": "10.1.10",
|
||||
"@storybook/vue3-vite": "10.1.10",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"acorn": "8.15.0",
|
||||
"astring": "1.9.0",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.7.1",
|
||||
"cypress": "15.8.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.6.2",
|
||||
"estree-walker": "3.0.3",
|
||||
|
|
@ -125,22 +125,22 @@
|
|||
"magic-string": "0.30.21",
|
||||
"micromatch": "4.0.8",
|
||||
"minimatch": "10.1.1",
|
||||
"msw": "2.12.4",
|
||||
"msw": "2.12.6",
|
||||
"msw-storybook-addon": "2.0.6",
|
||||
"nodemon": "3.1.11",
|
||||
"prettier": "3.7.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.1.3",
|
||||
"storybook": "10.1.5",
|
||||
"storybook": "10.1.10",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.21.0",
|
||||
"vite-plugin-glsl": "1.5.5",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "4.0.15",
|
||||
"vitest": "4.0.16",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.1.8",
|
||||
"vue-component-type-helpers": "3.2.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.8"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,12 +133,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</TransitionGroup>
|
||||
|
||||
<MkButton
|
||||
v-show="filesPaginator.canFetchOlder.value"
|
||||
v-appear="shouldEnableInfiniteScroll ? filesPaginator.fetchOlder : null"
|
||||
v-show="canFetchFiles"
|
||||
v-appear="shouldEnableInfiniteScroll ? fetchMoreFiles : null"
|
||||
:class="$style.loadMore"
|
||||
primary
|
||||
rounded
|
||||
@click="filesPaginator.fetchOlder()"
|
||||
@click="fetchMoreFiles"
|
||||
>{{ i18n.ts.loadMore }}</MkButton>
|
||||
|
||||
<div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
|
||||
|
|
@ -238,10 +238,9 @@ const filesPaginator = markRaw(new Paginator('drive/files', {
|
|||
params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
sort: sortModeSelect.value,
|
||||
sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value,
|
||||
}),
|
||||
}));
|
||||
|
||||
const foldersPaginator = markRaw(new Paginator('drive/folders', {
|
||||
limit: 30,
|
||||
canFetchDetection: 'limit',
|
||||
|
|
@ -250,6 +249,16 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', {
|
|||
}),
|
||||
}));
|
||||
|
||||
const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value));
|
||||
|
||||
async function fetchMoreFiles() {
|
||||
if (filesPaginator.order.value === 'oldest') {
|
||||
filesPaginator.fetchNewer();
|
||||
} else {
|
||||
filesPaginator.fetchOlder();
|
||||
}
|
||||
}
|
||||
|
||||
const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month');
|
||||
const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value));
|
||||
|
||||
|
|
@ -260,10 +269,10 @@ watch(sortModeSelect, () => {
|
|||
|
||||
async function initialize() {
|
||||
fetching.value = true;
|
||||
await Promise.all([
|
||||
foldersPaginator.init(),
|
||||
filesPaginator.init(),
|
||||
]);
|
||||
await foldersPaginator.reload();
|
||||
filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older';
|
||||
filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest';
|
||||
await filesPaginator.reload();
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,13 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
|
|||
}
|
||||
|
||||
async function onClick() {
|
||||
pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } });
|
||||
const isLoggedIn = await pleaseLogin({
|
||||
openOnRemote: {
|
||||
type: 'web',
|
||||
path: `/@${props.user.username}@${props.user.host ?? host}`,
|
||||
},
|
||||
});
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
wait.value = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js';
|
|||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
audio: Misskey.entities.DriveFile;
|
||||
|
|
@ -154,16 +155,11 @@ function hasFocus() {
|
|||
const playerEl = useTemplateRef('playerEl');
|
||||
const audioEl = useTemplateRef('audioEl');
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'));
|
||||
const hide = ref(shouldHideFileByDefault(props.audio));
|
||||
|
||||
async function reveal() {
|
||||
if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.audio))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
|
||||
<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="reveal">
|
||||
<div v-else-if="hide" :class="$style.sensitive" @click="reveal">
|
||||
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
|
|
@ -27,23 +27,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkMediaAudio from '@/components/MkMediaAudio.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
media: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const hide = ref(true);
|
||||
const hide = ref(shouldHideFileByDefault(props.media));
|
||||
|
||||
async function reveal() {
|
||||
if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.media))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import { i18n } from '@/i18n.js';
|
|||
import * as os from '@/os.js';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: Misskey.entities.DriveFile;
|
||||
|
|
@ -106,12 +107,8 @@ async function reveal(ev: MouseEvent) {
|
|||
|
||||
if (hide.value) {
|
||||
ev.stopPropagation();
|
||||
if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.image))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
|
@ -119,8 +116,8 @@ async function reveal(ev: MouseEvent) {
|
|||
}
|
||||
|
||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||
watch(() => props.image, () => {
|
||||
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore');
|
||||
watch(() => props.image, (newImage) => {
|
||||
hide.value = shouldHideFileByDefault(newImage);
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ import hasAudio from '@/utility/media-has-audio.js';
|
|||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
video: Misskey.entities.DriveFile;
|
||||
|
|
@ -176,15 +177,11 @@ function hasFocus() {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore'));
|
||||
const hide = ref(shouldHideFileByDefault(props.video));
|
||||
|
||||
async function reveal() {
|
||||
if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.video))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
|
|
|||
|
|
@ -468,8 +468,12 @@ if (!props.mock) {
|
|||
}
|
||||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
|
||||
|
|
@ -478,11 +482,12 @@ function renote() {
|
|||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
|
|
@ -491,8 +496,10 @@ function reply(): void {
|
|||
});
|
||||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
|
@ -621,10 +628,12 @@ async function clip(): Promise<void> {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
function getUnrenote(): MenuItem {
|
||||
return {
|
||||
|
|
@ -649,7 +658,6 @@ function showRenoteMenu(): void {
|
|||
};
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
|
|
|
|||
|
|
@ -448,8 +448,10 @@ if (appearNote.reactionAcceptance === 'likeOnly') {
|
|||
});
|
||||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function renote() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton });
|
||||
|
|
@ -459,8 +461,10 @@ function renote() {
|
|||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function reply() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
|
|
@ -470,8 +474,10 @@ function reply(): void {
|
|||
});
|
||||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
|
@ -569,9 +575,12 @@ async function clip(): Promise<void> {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
async function showRenoteMenu() {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
|
|
|
|||
|
|
@ -6,14 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<template v-for="file in note.files">
|
||||
<div
|
||||
v-if="(((
|
||||
(prefer.s.nsfw === 'force' || file.isSensitive) &&
|
||||
prefer.s.nsfw !== 'ignore'
|
||||
) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) &&
|
||||
!showingFiles.has(file.id)
|
||||
)"
|
||||
v-if="isHiding(file)"
|
||||
:class="[$style.filePreview, { [$style.square]: square }]"
|
||||
@click="showingFiles.add(file.id)"
|
||||
@click="reveal(file)"
|
||||
>
|
||||
<MkDriveFileThumbnail
|
||||
:file="file"
|
||||
|
|
@ -49,6 +44,7 @@ import * as Misskey from 'misskey-js';
|
|||
import { notePage } from '@/filters/note.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
|
|
@ -59,6 +55,24 @@ defineProps<{
|
|||
}>();
|
||||
|
||||
const showingFiles = ref<Set<string>>(new Set());
|
||||
|
||||
function isHiding(file: Misskey.entities.DriveFile) {
|
||||
if (shouldHideFileByDefault(file) && !showingFiles.value.has(file.id)) {
|
||||
if (!file.isSensitive && !file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function reveal(file: Misskey.entities.DriveFile) {
|
||||
if (!(await canRevealFile(file))) {
|
||||
return;
|
||||
}
|
||||
|
||||
showingFiles.value.add(file.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
|||
const vote = async (id: number) => {
|
||||
if (props.readOnly || closed.value || isVoted.value) return;
|
||||
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
|
|
|
|||
|
|
@ -329,8 +329,8 @@ const canSaveAsServerDraft = computed((): boolean => {
|
|||
return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null);
|
||||
});
|
||||
|
||||
const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
|
||||
const hashtags = computed(store.makeGetterSetter('postFormHashtags'));
|
||||
const withHashtags = store.model('postFormWithHashtags');
|
||||
const hashtags = store.model('postFormHashtags');
|
||||
|
||||
watch(text, () => {
|
||||
checkMissingMention();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.name"><MkUserName :user="user"/></span>
|
||||
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
|
||||
<span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span>
|
||||
</div>
|
||||
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@ok="save()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header><i class="ti ti-icons"></i> {{ widgetName }}</template>
|
||||
<template #header><i class="ti ti-icons"></i> {{ i18n.ts._widgets[widgetName] ?? widgetName }}</template>
|
||||
|
||||
<MkPreviewWithControls>
|
||||
<template #preview>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
// TODO: Misskeyのドメイン知識があるのでutilityなどに移動する
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
import { customRef, ref, watch, onScopeDispose } from 'vue';
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
import type { Ref } from 'vue';
|
||||
import { $i } from '@/i.js';
|
||||
|
|
@ -223,44 +223,43 @@ export class Pizzax<T extends StateDef> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 特定のキーの、簡易的なgetter/setterを作ります
|
||||
* 特定のキーの、簡易的なcomputed refを作ります
|
||||
* 主にvue上で設定コントロールのmodelとして使う用
|
||||
*/
|
||||
// TODO: 廃止
|
||||
public makeGetterSetter<K extends keyof T, R = T[K]['default']>(
|
||||
public model<K extends keyof T, R = T[K]['default']>(
|
||||
key: K,
|
||||
): Ref<R>;
|
||||
public model<K extends keyof T, R extends Exclude<any, T[K]['default']>>(
|
||||
key: K,
|
||||
getter: (v: T[K]['default']) => R,
|
||||
setter: (v: R) => T[K]['default'],
|
||||
): Ref<R>;
|
||||
|
||||
public model<K extends keyof T, R>(
|
||||
key: K,
|
||||
getter?: (v: T[K]['default']) => R,
|
||||
setter?: (v: R) => T[K]['default'],
|
||||
): {
|
||||
get: () => R;
|
||||
set: (value: R) => void;
|
||||
} {
|
||||
const valueRef = ref(this.s[key]);
|
||||
): Ref<R> {
|
||||
return customRef<R>((track, trigger) => {
|
||||
const watchStop = watch(this.r[key], () => {
|
||||
trigger();
|
||||
});
|
||||
|
||||
const stop = watch(this.r[key], val => {
|
||||
valueRef.value = val;
|
||||
onScopeDispose(() => {
|
||||
watchStop();
|
||||
}, true);
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
track();
|
||||
return (getter != null ? getter(this.s[key]) : this.s[key]) as R;
|
||||
},
|
||||
set: (value) => {
|
||||
const val = setter != null ? setter(value) : value;
|
||||
this.set(key, val as T[K]['default']);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
// TODO: VueのcustomRef使うと良い感じになるかも
|
||||
return {
|
||||
get: () => {
|
||||
if (getter) {
|
||||
return getter(valueRef.value);
|
||||
} else {
|
||||
return valueRef.value;
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
const val = setter ? setter(value) : value;
|
||||
this.set(key, val);
|
||||
valueRef.value = val;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// localStorage => indexedDBのマイグレーション
|
||||
|
|
|
|||
|
|
@ -709,8 +709,8 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
|
|||
}));
|
||||
}
|
||||
|
||||
export function post(props: PostFormProps = {}): Promise<void> {
|
||||
pleaseLogin({
|
||||
export async function post(props: PostFormProps = {}): Promise<void> {
|
||||
const isLoggedIn = await pleaseLogin({
|
||||
openOnRemote: (props.initialText || props.initialNote ? {
|
||||
type: 'share',
|
||||
params: {
|
||||
|
|
@ -720,6 +720,7 @@ export function post(props: PostFormProps = {}): Promise<void> {
|
|||
},
|
||||
} : undefined),
|
||||
});
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
return new Promise(resolve => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_spacer" style="--MI_SPACER-w: 900px;">
|
||||
<div class="_gaps">
|
||||
<MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo>
|
||||
<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
|
||||
<MkInfo v-if="announcementsStatus === 'active' && announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
|
||||
|
||||
<MkSelect v-model="announcementsStatus" :items="announcementsStatusDef">
|
||||
<template #label>{{ i18n.ts.filter }}</template>
|
||||
|
|
|
|||
|
|
@ -151,9 +151,11 @@ function shareWithNote() {
|
|||
});
|
||||
}
|
||||
|
||||
function like() {
|
||||
async function like() {
|
||||
if (!flash.value) return;
|
||||
pleaseLogin();
|
||||
|
||||
const isLoggedIn = await pleaseLogin();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.apiWithDialog('flash/like', {
|
||||
flashId: flash.value.id,
|
||||
|
|
@ -165,7 +167,9 @@ function like() {
|
|||
|
||||
async function unlike() {
|
||||
if (!flash.value) return;
|
||||
pleaseLogin();
|
||||
|
||||
const isLoggedIn = await pleaseLogin();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
|
|
|
|||
|
|
@ -197,7 +197,8 @@ async function matchHeatbeat() {
|
|||
}
|
||||
|
||||
async function matchUser() {
|
||||
pleaseLogin();
|
||||
const isLoggedIn = await pleaseLogin();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const user = await os.selectUser({ includeSelf: false, localOnly: true });
|
||||
if (user == null) return;
|
||||
|
|
@ -207,8 +208,9 @@ async function matchUser() {
|
|||
matchHeatbeat();
|
||||
}
|
||||
|
||||
function matchAny(ev: MouseEvent) {
|
||||
pleaseLogin();
|
||||
async function matchAny(ev: MouseEvent) {
|
||||
const isLoggedIn = await pleaseLogin();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._reversi.allowIrregularRules,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const items = ref(prefer.s.menu.map(x => ({
|
|||
})));
|
||||
const itemTypeValues = computed(() => items.value.map(x => x.type));
|
||||
|
||||
const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
|
||||
const menuDisplay = store.model('menuDisplay');
|
||||
const showNavbarSubButtons = prefer.model('showNavbarSubButtons');
|
||||
|
||||
async function addItem() {
|
||||
|
|
|
|||
|
|
@ -855,7 +855,7 @@ const $i = ensureSignin();
|
|||
|
||||
const lang = ref(miLocalStorage.getItem('lang'));
|
||||
const dataSaver = ref(prefer.s.dataSaver);
|
||||
const realtimeMode = computed(store.makeGetterSetter('realtimeMode'));
|
||||
const realtimeMode = store.model('realtimeMode');
|
||||
|
||||
const overridedDeviceKind = prefer.model('overridedDeviceKind');
|
||||
const pollingInterval = prefer.model('pollingInterval');
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ const $i = ensureSignin();
|
|||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
const reactionAcceptance = computed(store.makeGetterSetter('reactionAcceptance'));
|
||||
const reactionAcceptance = store.model('reactionAcceptance');
|
||||
|
||||
function assertVaildLang(lang: string | null): lang is keyof typeof langmap {
|
||||
return lang != null && lang in langmap;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { customRef, ref, watch, onScopeDispose } from 'vue';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { host, version } from '@@/js/config.js';
|
||||
import { PREF_DEF } from './def.js';
|
||||
import type { Ref, WritableComputedRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
|
|
@ -299,36 +299,39 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||
* 特定のキーの、簡易的なcomputed refを作ります
|
||||
* 主にvue上で設定コントロールのmodelとして使う用
|
||||
*/
|
||||
public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>(
|
||||
public model<K extends keyof PREF, V = ValueOf<K>>(
|
||||
key: K,
|
||||
): Ref<V>;
|
||||
public model<K extends keyof PREF, V extends Exclude<any, ValueOf<K>>>(
|
||||
key: K,
|
||||
getter: (v: ValueOf<K>) => V,
|
||||
setter: (v: V) => ValueOf<K>,
|
||||
): Ref<V>;
|
||||
|
||||
public model<K extends keyof PREF, V>(
|
||||
key: K,
|
||||
getter?: (v: ValueOf<K>) => V,
|
||||
setter?: (v: V) => ValueOf<K>,
|
||||
): WritableComputedRef<V> {
|
||||
const valueRef = ref(this.s[key]);
|
||||
): Ref<V> {
|
||||
return customRef<V>((track, trigger) => {
|
||||
const watchStop = watch(this.r[key], () => {
|
||||
trigger();
|
||||
});
|
||||
|
||||
const stop = watch(this.r[key], val => {
|
||||
valueRef.value = val;
|
||||
});
|
||||
onScopeDispose(() => {
|
||||
watchStop();
|
||||
}, true);
|
||||
|
||||
// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
// TODO: VueのcustomRef使うと良い感じになるかも
|
||||
return computed({
|
||||
get: () => {
|
||||
if (getter) {
|
||||
return getter(valueRef.value);
|
||||
} else {
|
||||
return valueRef.value;
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
const val = setter ? setter(value) : value;
|
||||
this.commit(key, val);
|
||||
valueRef.value = val;
|
||||
},
|
||||
return {
|
||||
get: () => {
|
||||
track();
|
||||
return (getter != null ? getter(this.s[key]) : this.s[key]) as V;
|
||||
},
|
||||
set: (value) => {
|
||||
const val = setter != null ? setter(value) : value;
|
||||
this.commit(key, val as ValueOf<K>);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ const props = defineProps<{
|
|||
|
||||
const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD);
|
||||
const menu = ref(prefer.s.menu);
|
||||
// const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
|
||||
// const menuDisplay = store.model('menuDisplay');
|
||||
const otherNavItemIndicated = computed<boolean>(() => {
|
||||
for (const def in navbarItemDef) {
|
||||
if (menu.value.includes(def)) continue;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export async function pleaseLogin(opts: {
|
|||
path?: string;
|
||||
message?: string;
|
||||
openOnRemote?: OpenOnRemoteOptions;
|
||||
} = {}) {
|
||||
if ($i) return;
|
||||
} = {}): Promise<boolean> {
|
||||
if ($i != null) return true;
|
||||
|
||||
let _openOnRemote: OpenOnRemoteOptions | undefined = undefined;
|
||||
|
||||
|
|
@ -71,5 +71,5 @@ export async function pleaseLogin(opts: {
|
|||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
throw new Error('signin required');
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean {
|
||||
if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file.isSensitive && prefer.s.nsfw !== 'ignore') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function canRevealFile(file: Misskey.entities.DriveFile): Promise<boolean> {
|
||||
if (file.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkA :to="userPage(item.user)" style="overflow: clip;">
|
||||
<MkUserCardMini :user="item.user" :withChart="false" style="text-overflow: ellipsis; background: inherit; border-radius: unset;">
|
||||
<template #sub>
|
||||
<span>{{ countdownDate }}</span>
|
||||
<span> / </span>
|
||||
<span class="_monospace">@{{ acct(item.user) }}</span>
|
||||
</template>
|
||||
</MkUserCardMini>
|
||||
</MkA>
|
||||
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})">
|
||||
<i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useLowresTime } from '@/composables/use-lowres-time.js';
|
||||
import { userPage, acct } from '@/filters/user.js';
|
||||
|
||||
const props = defineProps<{
|
||||
item: Misskey.entities.UsersGetFollowingBirthdayUsersResponse[number];
|
||||
}>();
|
||||
|
||||
const now = useLowresTime();
|
||||
const nowDate = computed(() => {
|
||||
const date = new Date(now.value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
});
|
||||
const birthdayDate = computed(() => {
|
||||
const [year, month, day] = props.item.birthday.split('-').map((v) => parseInt(v, 10));
|
||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
});
|
||||
|
||||
const countdownDate = computed(() => {
|
||||
const days = Math.floor((birthdayDate.value.getTime() - nowDate.value.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (days === 0) {
|
||||
return i18n.ts.today;
|
||||
} else if (days > 0) {
|
||||
return i18n.tsx._timeIn.days({ n: days });
|
||||
} else {
|
||||
return i18n.tsx._ago.daysAgo({ n: Math.abs(days) });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 56px;
|
||||
}
|
||||
|
||||
.post {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin-right: 16px;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 100%;
|
||||
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
|
||||
}
|
||||
}
|
||||
|
||||
.postIcon {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,34 +4,43 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
|
||||
<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" class="mkw-bdayfollowings">
|
||||
<template #icon><i class="ti ti-cake"></i></template>
|
||||
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
|
||||
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ti ti-refresh"></i></button></template>
|
||||
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="fetch"><i class="ti ti-refresh"></i></button></template>
|
||||
|
||||
<div :class="$style.bdayFRoot">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else-if="users.length > 0" :class="$style.bdayFGrid">
|
||||
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee!" link preview></MkAvatar>
|
||||
<MkPagination v-slot="{ items }" :paginator="birthdayUsersPaginator">
|
||||
<div>
|
||||
<template v-for="(user, i) in items" :key="user.id">
|
||||
<div
|
||||
v-if="i > 0 && isSeparatorNeeded(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)"
|
||||
>
|
||||
<div :class="$style.date">
|
||||
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.prevText }}</span>
|
||||
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
|
||||
<span>{{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.nextText }} <i class="ti ti-chevron-down"></i></span>
|
||||
</div>
|
||||
<XUser :class="$style.user" :item="user" />
|
||||
</div>
|
||||
<XUser v-else :class="$style.user" :item="user" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-else :class="$style.bdayFFallback">
|
||||
<MkResult type="empty"/>
|
||||
</div>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { computed, markRaw, ref, watch } from 'vue';
|
||||
import { useLowresTime } from '@/composables/use-lowres-time.js';
|
||||
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
|
||||
import { useWidgetPropsManager } from './widget.js';
|
||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import XUser from './WidgetBirthdayFollowings.user.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
|
||||
const name = 'birthdayFollowings';
|
||||
|
||||
|
|
@ -41,6 +50,29 @@ const widgetPropsDef = {
|
|||
label: i18n.ts._widgetOptions.showHeader,
|
||||
default: true,
|
||||
},
|
||||
height: {
|
||||
type: 'number' as const,
|
||||
label: i18n.ts._widgetOptions.height,
|
||||
default: 300,
|
||||
},
|
||||
period: {
|
||||
type: 'radio' as const,
|
||||
label: i18n.ts._widgetOptions._birthdayFollowings.period,
|
||||
default: '3day',
|
||||
options: [{
|
||||
value: 'today' as const,
|
||||
label: i18n.ts.today,
|
||||
}, {
|
||||
value: '3day' as const,
|
||||
label: i18n.tsx.dayX({ day: 3 }),
|
||||
}, {
|
||||
value: 'week' as const,
|
||||
label: i18n.ts.oneWeek,
|
||||
}, {
|
||||
value: 'month' as const,
|
||||
label: i18n.ts.oneMonth,
|
||||
}],
|
||||
},
|
||||
} satisfies FormWithDefault;
|
||||
|
||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
||||
|
|
@ -48,62 +80,84 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
|||
const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
||||
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
||||
|
||||
const { widgetProps, configure } = useWidgetPropsManager(name,
|
||||
const { widgetProps, configure } = useWidgetPropsManager(
|
||||
name,
|
||||
widgetPropsDef,
|
||||
props,
|
||||
emit,
|
||||
);
|
||||
|
||||
const users = ref<Misskey.Endpoints['users/following']['res']>([]);
|
||||
const fetching = ref(true);
|
||||
let lastFetchedAt = '1970-01-01';
|
||||
const now = useLowresTime();
|
||||
const nextDay = new Date();
|
||||
nextDay.setHours(24, 0, 0, 0);
|
||||
let nextDayMidnightTime = nextDay.getTime();
|
||||
|
||||
const fetch = () => {
|
||||
if (!$i) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
const begin = ref<Date>(new Date());
|
||||
const end = computed(() => {
|
||||
switch (widgetProps.period) {
|
||||
case '3day':
|
||||
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 3);
|
||||
case 'week':
|
||||
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 7);
|
||||
case 'month':
|
||||
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 30);
|
||||
default:
|
||||
return begin.value;
|
||||
}
|
||||
});
|
||||
|
||||
const lfAtD = new Date(lastFetchedAt);
|
||||
lfAtD.setHours(0, 0, 0, 0);
|
||||
const birthdayUsersPaginator = markRaw(new Paginator('users/get-following-birthday-users', {
|
||||
limit: 18,
|
||||
offsetMode: true,
|
||||
computedParams: computed(() => {
|
||||
if (widgetProps.period === 'today') {
|
||||
return {
|
||||
birthday: {
|
||||
month: begin.value.getMonth() + 1,
|
||||
day: begin.value.getDate(),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
birthday: {
|
||||
begin: {
|
||||
month: begin.value.getMonth() + 1,
|
||||
day: begin.value.getDate(),
|
||||
},
|
||||
end: {
|
||||
month: end.value.getMonth() + 1,
|
||||
day: end.value.getDate(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
function fetch() {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now > lfAtD) {
|
||||
actualFetch();
|
||||
|
||||
lastFetchedAt = now.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
function actualFetch() {
|
||||
if ($i == null) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
fetching.value = true;
|
||||
misskeyApi('users/following', {
|
||||
limit: 18,
|
||||
birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`,
|
||||
userId: $i.id,
|
||||
}).then(res => {
|
||||
users.value = res;
|
||||
window.setTimeout(() => {
|
||||
// 早すぎるとチカチカする
|
||||
fetching.value = false;
|
||||
}, 100);
|
||||
});
|
||||
begin.value = now;
|
||||
}
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
});
|
||||
const UPDATE_INTERVAL = 1000 * 60;
|
||||
let nextDayTimer: number | null = null;
|
||||
|
||||
watch(now, (to) => {
|
||||
// 次回更新までに日付が変わる場合、日付が変わった直後に強制的に更新するタイマーをセットする
|
||||
if (nextDayMidnightTime - to <= UPDATE_INTERVAL) {
|
||||
if (nextDayTimer != null) {
|
||||
window.clearTimeout(nextDayTimer);
|
||||
nextDayTimer = null;
|
||||
}
|
||||
|
||||
nextDayTimer = window.setTimeout(() => {
|
||||
fetch();
|
||||
nextDay.setHours(24, 0, 0, 0);
|
||||
nextDayMidnightTime = nextDay.getTime();
|
||||
nextDayTimer = null;
|
||||
}, nextDayMidnightTime - to);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
defineExpose<WidgetComponentExpose>({
|
||||
name,
|
||||
|
|
@ -113,24 +167,24 @@ defineExpose<WidgetComponentExpose>({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.bdayFRoot {
|
||||
overflow: hidden;
|
||||
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--MI-margin) * 2));
|
||||
}
|
||||
.bdayFGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 42px);
|
||||
grid-template-rows: repeat(3, 42px);
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
margin: var(--MI-margin) auto;
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
background: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.bdayFFallback {
|
||||
height: 100%;
|
||||
.user {
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
font-size: 85%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
opacity: 0.75;
|
||||
padding: 8px 8px;
|
||||
margin: 0 auto;
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
|
|||
canceled: true;
|
||||
}>((resolve) => {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWidgetSettingsDialog.vue')), {
|
||||
widgetName: i18n.ts._widgets[name] ?? name,
|
||||
widgetName: name,
|
||||
form: form,
|
||||
currentSettings: widgetProps,
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@
|
|||
],
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"chokidar": "5.0.0",
|
||||
"esbuild": "0.27.1",
|
||||
"esbuild": "0.27.2",
|
||||
"execa": "9.6.1",
|
||||
"nodemon": "3.1.11",
|
||||
"tsx": "4.21.0"
|
||||
|
|
|
|||
|
|
@ -9885,7 +9885,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"clicker": string;
|
||||
/**
|
||||
* 今日誕生日のユーザー
|
||||
* もうすぐ誕生日のユーザー
|
||||
*/
|
||||
"birthdayFollowings": string;
|
||||
/**
|
||||
|
|
@ -10024,6 +10024,12 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"reverse": string;
|
||||
};
|
||||
"_birthdayFollowings": {
|
||||
/**
|
||||
* 期間
|
||||
*/
|
||||
"period": string;
|
||||
};
|
||||
};
|
||||
"_cw": {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@
|
|||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/wawoff2": "1.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"harfbuzzjs": "0.4.13",
|
||||
"harfbuzzjs": "0.4.14",
|
||||
"tsx": "4.21.0",
|
||||
"wawoff2": "2.0.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"esbuild": "0.27.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"esbuild": "0.27.2",
|
||||
"execa": "9.6.1",
|
||||
"nodemon": "3.1.11"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021-2025 syuilo and other contributors
|
||||
Copyright (c) 2021-2026 syuilo and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
|
|
@ -2121,6 +2121,8 @@ declare namespace entities {
|
|||
UsersFollowingResponse,
|
||||
UsersGalleryPostsRequest,
|
||||
UsersGalleryPostsResponse,
|
||||
UsersGetFollowingBirthdayUsersRequest,
|
||||
UsersGetFollowingBirthdayUsersResponse,
|
||||
UsersGetFrequentlyRepliedUsersRequest,
|
||||
UsersGetFrequentlyRepliedUsersResponse,
|
||||
UsersListsCreateRequest,
|
||||
|
|
@ -3727,6 +3729,12 @@ type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBo
|
|||
// @public (undocumented)
|
||||
type UsersGalleryPostsResponse = operations['users___gallery___posts']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetFollowingBirthdayUsersRequest = operations['users___get-following-birthday-users']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetFollowingBirthdayUsersResponse = operations['users___get-following-birthday-users']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json'];
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@
|
|||
"generate": "tsx src/generator.ts && eslint ./built/**/*.ts --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@readme/openapi-parser": "5.2.1",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@readme/openapi-parser": "5.4.0",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"openapi-typescript": "7.10.1",
|
||||
"ts-case-convert": "2.1.0",
|
||||
"tsx": "4.21.0",
|
||||
"eslint": "9.39.1"
|
||||
"eslint": "9.39.2"
|
||||
},
|
||||
"files": [
|
||||
"built"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2025.12.2",
|
||||
"version": "2026.1.0-alpha.0",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
|
@ -38,16 +38,16 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.55.2",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"esbuild": "0.27.1",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"esbuild": "0.27.2",
|
||||
"execa": "9.6.1",
|
||||
"ncp": "2.0.0",
|
||||
"nodemon": "3.1.11",
|
||||
"tsd": "0.33.0",
|
||||
"vitest": "4.0.15",
|
||||
"vitest": "4.0.16",
|
||||
"vitest-websocket-mock": "0.5.0"
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -4532,6 +4532,17 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* Find users who have a birthday on the specified range.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:account*
|
||||
*/
|
||||
request<E extends 'users/get-following-birthday-users', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* Get a list of other users that the specified user frequently replies to.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -616,6 +616,8 @@ import type {
|
|||
UsersFollowingResponse,
|
||||
UsersGalleryPostsRequest,
|
||||
UsersGalleryPostsResponse,
|
||||
UsersGetFollowingBirthdayUsersRequest,
|
||||
UsersGetFollowingBirthdayUsersResponse,
|
||||
UsersGetFrequentlyRepliedUsersRequest,
|
||||
UsersGetFrequentlyRepliedUsersResponse,
|
||||
UsersListsCreateRequest,
|
||||
|
|
@ -1067,6 +1069,7 @@ export type Endpoints = {
|
|||
'users/followers': { req: UsersFollowersRequest; res: UsersFollowersResponse };
|
||||
'users/following': { req: UsersFollowingRequest; res: UsersFollowingResponse };
|
||||
'users/gallery/posts': { req: UsersGalleryPostsRequest; res: UsersGalleryPostsResponse };
|
||||
'users/get-following-birthday-users': { req: UsersGetFollowingBirthdayUsersRequest; res: UsersGetFollowingBirthdayUsersResponse };
|
||||
'users/get-frequently-replied-users': { req: UsersGetFrequentlyRepliedUsersRequest; res: UsersGetFrequentlyRepliedUsersResponse };
|
||||
'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse };
|
||||
'users/lists/create-from-public': { req: UsersListsCreateFromPublicRequest; res: UsersListsCreateFromPublicResponse };
|
||||
|
|
|
|||
|
|
@ -619,6 +619,8 @@ export type UsersFollowingRequest = operations['users___following']['requestBody
|
|||
export type UsersFollowingResponse = operations['users___following']['responses']['200']['content']['application/json'];
|
||||
export type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBody']['content']['application/json'];
|
||||
export type UsersGalleryPostsResponse = operations['users___gallery___posts']['responses']['200']['content']['application/json'];
|
||||
export type UsersGetFollowingBirthdayUsersRequest = operations['users___get-following-birthday-users']['requestBody']['content']['application/json'];
|
||||
export type UsersGetFollowingBirthdayUsersResponse = operations['users___get-following-birthday-users']['responses']['200']['content']['application/json'];
|
||||
export type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json'];
|
||||
export type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json'];
|
||||
export type UsersListsCreateRequest = operations['users___lists___create']['requestBody']['content']['application/json'];
|
||||
|
|
|
|||
|
|
@ -3717,6 +3717,15 @@ export type paths = {
|
|||
*/
|
||||
post: operations['users___gallery___posts'];
|
||||
};
|
||||
'/users/get-following-birthday-users': {
|
||||
/**
|
||||
* users/get-following-birthday-users
|
||||
* @description Find users who have a birthday on the specified range.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:account*
|
||||
*/
|
||||
post: operations['users___get-following-birthday-users'];
|
||||
};
|
||||
'/users/get-frequently-replied-users': {
|
||||
/**
|
||||
* users/get-frequently-replied-users
|
||||
|
|
@ -34847,6 +34856,7 @@ export interface operations {
|
|||
untilDate?: number;
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
/** @description @deprecated use get-following-birthday-users instead. */
|
||||
birthday?: string | null;
|
||||
};
|
||||
};
|
||||
|
|
@ -34982,6 +34992,92 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
'users___get-following-birthday-users': {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
/** @default 0 */
|
||||
offset?: number;
|
||||
birthday: {
|
||||
month: number;
|
||||
day: number;
|
||||
} | {
|
||||
begin: {
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
end: {
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
id: string;
|
||||
birthday: string;
|
||||
user: components['schemas']['UserLite'];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
'users___get-frequently-replied-users': {
|
||||
requestBody: {
|
||||
content: {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@
|
|||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"esbuild": "0.27.1",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"esbuild": "0.27.2",
|
||||
"execa": "9.6.1",
|
||||
"nodemon": "3.1.11"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"esbuild": "0.27.1",
|
||||
"esbuild": "0.27.2",
|
||||
"idb-keyval": "6.2.2",
|
||||
"misskey-js": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"nodemon": "3.1.11"
|
||||
|
|
|
|||
4538
pnpm-lock.yaml
4538
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue