Merge branch 'develop' into fix-signout

This commit is contained in:
かっこかり 2025-12-31 22:35:12 +09:00 committed by GitHub
commit 89cf12d4b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 939 additions and 317 deletions

View File

@ -1,7 +1,12 @@
## Unreleased
### Note
- `users/following``birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。
### General
-
- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey)
- 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました
### Client
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
@ -13,7 +18,9 @@
- 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

View File

@ -2602,7 +2602,7 @@ _widgets:
_userList:
chooseList: "リストを選択"
clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー"
birthdayFollowings: "もうすぐ誕生日のユーザー"
chat: "ダイレクトメッセージ"
_widgetOptions:
@ -2641,6 +2641,8 @@ _widgetOptions:
shuffle: "表示順をシャッフル"
duration: "ティッカーのスクロール速度(秒)"
reverse: "逆方向にスクロール"
_birthdayFollowings:
period: "期間"
_cw:
hide: "隠す"

View File

@ -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)`);
}
}

View File

@ -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,

View File

@ -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';

View File

@ -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);
}

View File

@ -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'> });
});
}
}

View File

@ -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');
}

View File

@ -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}/`);
});
});
});

View File

@ -69,7 +69,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="(f, i) in foldersPaginator.items.value"
:key="f.id"
v-anim="i"
:class="$style.folder"
:folder="f"
:selectMode="select === 'folder'"
:isSelected="selectedFolders.some(x => x.id === f.id)"
@ -102,7 +101,6 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<XFile
v-for="file in item.items" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@ -125,7 +123,6 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<XFile
v-for="file in filesPaginator.items.value" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"

View File

@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<MkPagination v-slot="{ items }" :paginator="paginator">
<div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]">
<div
:class="{
[$style.grid]: viewMode === 'grid',
[$style.list]: viewMode === 'list',
'_gaps_s': viewMode === 'list',
}"
>
<MkA
v-for="file in items"
:key="file.id"

View File

@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { reactive, useTemplateRef } from 'vue';
import { ref, useTemplateRef } from 'vue';
import type { Form } from '@/utility/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkForm from '@/components/MkForm.vue';
@ -47,7 +47,7 @@ const emit = defineEmits<{
const dialog = useTemplateRef('dialog');
const values = reactive((() => {
const values = ref((() => {
const obj: Record<string, any> = {};
for (const item in props.form) {
if ('default' in props.form[item]) {
@ -61,7 +61,7 @@ const values = reactive((() => {
function ok() {
emit('done', {
result: values,
result: values.value,
});
dialog.value?.close();
}

View File

@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span><MkEllipsis/></span>
</span>
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]">
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1">
<component :is="item.component" v-bind="item.props"/>
</div>

View File

@ -118,7 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.draftActions" class="_buttons">
<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
<MkButton
:class="$style.itemButton"
small
@click="cancelSchedule(draft)"
>
@ -126,7 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton>
<!-- TODO
<MkButton
:class="$style.itemButton"
small
@click="reSchedule(draft)"
>
@ -136,7 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<MkButton
v-else
:class="$style.itemButton"
small
@click="restoreDraft(draft)"
>
@ -147,7 +144,6 @@ SPDX-License-Identifier: AGPL-3.0-only
danger
small
:iconOnly="true"
:class="$style.itemButton"
style="margin-left: auto;"
@click="deleteDraft(draft)"
>

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.header">
<div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu">
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" class="_button" @click="openAccountMenu">
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
</button>
</div>
@ -1469,9 +1469,6 @@ defineExpose({
padding: 8px;
}
.account {
}
.avatar {
display: block;
width: 28px;

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="preview"></slot>
</div>
<div v-if="previewLoading" :class="$style.previewLoading">
<MkLoading :class="$style.previewLoadingSpinner"/>
<MkLoading/>
</div>
</div>
<div :class="$style.controls">

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps_m">
<div class="_gaps_m">
<MkInput v-model="q_name" data-cy-server-name>
<template #label>{{ i18n.ts.instanceName }}</template>
</MkInput>
@ -370,8 +370,3 @@ function applySettings() {
});
}
</script>
<style lang="scss" module>
.root {
}
</style>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root" @click="(ev) => emit('click', ev)">
<span v-if="iconClass" :class="[$style.icon, iconClass]"></span>
<span :class="$style.content">{{ content }}</span>
<span>{{ content }}</span>
<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
</MkButton>

View File

@ -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"/><slot name="nameSuffix"></slot></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>

View File

@ -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>
@ -68,10 +68,10 @@ const emit = defineEmits<{
const dialog = useTemplateRef('dialog');
const settings = reactive<Record<string, any>>(deepClone(props.currentSettings));
const settings = ref<Record<string, any>>(deepClone(props.currentSettings));
function save() {
emit('saved', deepClone(settings));
emit('saved', deepClone(settings.value));
dialog.value?.close();
}

View File

@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-if="show" ref="el" :class="[$style.root]">
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i"/>
</div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="[$style.buttons, $style.buttonsLeft]"></div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttons"></div>
<template v-if="pageMetadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
</template>
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="[$style.buttons, $style.buttonsRight]">
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttons">
<template v-for="action in actions">
<button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
<div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps">
<div :class="$style.root" class="_gaps">
<img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/>
<MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/>
<img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/>

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
<div ref="rootEl" :class="reversed ? '_pageScrollableReversed' : '_pageScrollable'">
<MkStickyContainer>
<template #header>
<MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/>

View File

@ -39,7 +39,7 @@ export const directives = {
} as Record<string, Directive>;
declare module 'vue' {
export interface ComponentCustomProperties {
export interface GlobalDirectives {
vUserPreview: typeof userPreviewDirective;
vGetSize: typeof getSizeDirective;
vRipple: typeof rippleDirective;

View File

@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="headerTab" :tabs="headerTabs">
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
<XGridRemoteComponent v-else-if="headerTab === 'remote'" :class="$style.remote"/>
<XRegisterComponent v-else-if="headerTab === 'register'" :class="$style.register"/>
<XGridRemoteComponent v-else-if="headerTab === 'remote'"/>
<XRegisterComponent v-else-if="headerTab === 'register'"/>
</PageWithHeader>
</template>

View File

@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<MkTabs
v-model:tab="jobState"
:class="$style.jobsTabs" :tabs="[{
:tabs="[{
key: 'all',
title: 'All',
icon: 'ti ti-code-asterisk',
@ -359,8 +359,4 @@ definePage(() => ({
font-size: 85%;
margin: 6px 0;
}
.jobsTabs {
}
</style>

View File

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<div :class="$style.footer">

View File

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<div :class="$style.view">
<video ref="videoEl" :class="$style.video" autoplay muted playsinline></video>
<div ref="overlayEl" :class="$style.overlay"></div>
<div ref="overlayEl"></div>
<div :class="$style.controls">
<MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton>

View File

@ -71,7 +71,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserCardMini
:user="user"
:withChart="false"
:class="$style.userSelectedCard"
/>
</div>
<div>

View File

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkButton rounded full @click="emit('showMore')">{{ i18n.ts.showMore }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
<p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
<p v-if="!fetching && notes.length == 0">{{ i18n.ts.nothing }}</p>
</div>
</MkContainer>
</template>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div :class="$style.root">
<div>
<MkStickyContainer>
<template #header>
<MkTab

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="[$style.root, acrylic ? $style.acrylic : null]">
<div :class="$style.body">
<div :class="$style.left">
<div>
<button v-click-anime :class="[$style.item, $style.instance]" class="_button" @click="openInstanceMenu">
<img :class="$style.instanceIcon" :src="instance.iconUrl ?? '/favicon.ico'" draggable="false"/>
</button>

View File

@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<div v-if="!forceIconOnly && prefer.r.showNavbarSubButtons.value" :class="$style.subButtons">
<div :class="[$style.subButton, $style.menuEditButton]">
<div :class="$style.subButton">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>
@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="!props.asDrawer">
<div :class="$style.subButtonGapFill"></div>
<div :class="$style.subButtonGapFillDivider"></div>
<div :class="[$style.subButton, $style.toggleButton]">
<div :class="$style.subButton">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>

View File

@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="id in ids"
:ref="id"
:key="id"
:class="[$style.column, { '_shadow': withWallpaper }]"
:class="{ '_shadow': withWallpaper }"
:column="columns.find(c => c.id === id)!"
:isStacked="ids.length > 1"
@headerWheel="onWheel"

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div>
<div :class="$style.contents">
<!--
デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない)
@ -57,9 +57,6 @@ function goToDeck() {
</script>
<style lang="scss" module>
.root {
}
.contents {
display: flex;
flex-direction: column;

View File

@ -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>

View File

@ -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>

View File

@ -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,
}, {

View File

@ -9893,7 +9893,7 @@ export interface Locale extends ILocale {
*/
"clicker": string;
/**
*
*
*/
"birthdayFollowings": string;
/**
@ -10032,6 +10032,12 @@ export interface Locale extends ILocale {
*/
"reverse": string;
};
"_birthdayFollowings": {
/**
*
*/
"period": string;
};
};
"_cw": {
/**

View File

@ -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'];

View File

@ -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.
*

View File

@ -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 };

View File

@ -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'];

View File

@ -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: {