Merge tag '2024.3.1-io.4' into host

This commit is contained in:
まっちゃとーにゅ 2024-03-29 19:16:44 +09:00
commit 1d7f763ed9
No known key found for this signature in database
GPG key ID: 6AFBBF529601C1DB
34 changed files with 2806 additions and 1929 deletions

View file

@ -18,6 +18,7 @@
- 実装の都合により、プラグインは1つエラーを起こした時に即時停止するようになりました - 実装の都合により、プラグインは1つエラーを起こした時に即時停止するようになりました
- Enhance: ページのデザインを変更 - Enhance: ページのデザインを変更
- Enhance: 2要素認証ワンタイムパスワードの入力欄を改善 - Enhance: 2要素認証ワンタイムパスワードの入力欄を改善
- Enhance: 「今日誕生日のフォロー中ユーザー」ウィジェットを手動でリロードできるように
- Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 一部のページ内リンクが正しく動作しない問題を修正
- Fix: 周年の実績が閏年を考慮しない問題を修正 - Fix: 周年の実績が閏年を考慮しない問題を修正
- Fix: ローカルURLのプレビューポップアップが左上に表示される - Fix: ローカルURLのプレビューポップアップが左上に表示される
@ -27,6 +28,7 @@
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/528) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/528)
- Fix: コードブロックのシンタックスハイライトで使用される定義ファイルをCDNから取得するように #13177 - Fix: コードブロックのシンタックスハイライトで使用される定義ファイルをCDNから取得するように #13177
- CDNから取得せずMisskey本体にバンドルする場合は`pacakges/frontend/vite.config.ts`を修正してください。 - CDNから取得せずMisskey本体にバンドルする場合は`pacakges/frontend/vite.config.ts`を修正してください。
- Fix: タイムゾーンによっては、「今日誕生日のフォロー中ユーザー」ウィジェットが正しく動作しない問題を修正
### Server ### Server
- Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに - Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに

View file

@ -2153,6 +2153,7 @@ _widgets:
chooseList: "Select a list" chooseList: "Select a list"
clicker: "Clicker" clicker: "Clicker"
birthdayFollowings: "Users who celebrate their birthday today" birthdayFollowings: "Users who celebrate their birthday today"
birthdaySoon: "Users who will celebrate their birthday soon"
_cw: _cw:
hide: "Hide" hide: "Hide"
show: "Show content" show: "Show content"
@ -2574,4 +2575,18 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "Offline - cannot connect to the server" title: "Offline - cannot connect to the server"
header: "Unable to connect to the server" header: "Unable to connect to the server"
_skebStatus:
_genres:
art: "Artwork"
comic: "Comic"
voice: "Voice"
novel: "Text"
video: "Movie"
music: "Music"
correction: "Advice"
seeking: "Seeking"
stopped: "Stopped"
client: "Client"
yenX: "JPY {x}"
nWorks: "Delivered {n} works"
nRequests: "Requested {n} times"

62
locales/index.d.ts vendored
View file

@ -4865,7 +4865,7 @@ export interface Locale extends ILocale {
*/ */
"wellKnownWebsites": string; "wellKnownWebsites": string;
/** /**
* AND指定になりOR指定になります * AND指定になりOR指定になります
*/ */
"wellKnownWebsitesDescription": string; "wellKnownWebsitesDescription": string;
/** /**
@ -8396,6 +8396,10 @@ export interface Locale extends ILocale {
* *
*/ */
"birthdayFollowings": string; "birthdayFollowings": string;
/**
*
*/
"birthdaySoon": string;
}; };
"_cw": { "_cw": {
/** /**
@ -10109,6 +10113,62 @@ export interface Locale extends ILocale {
*/ */
"summaryProxyDescription2": string; "summaryProxyDescription2": string;
}; };
"_skebStatus": {
"_genres": {
/**
*
*/
"art": string;
/**
*
*/
"comic": string;
/**
*
*/
"voice": string;
/**
*
*/
"novel": string;
/**
*
*/
"video": string;
/**
*
*/
"music": string;
/**
*
*/
"correction": string;
};
/**
*
*/
"seeking": string;
/**
*
*/
"stopped": string;
/**
*
*/
"client": string;
/**
* {x}
*/
"yenX": ParameterizedString<"x">;
/**
* {n}
*/
"nWorks": ParameterizedString<"n">;
/**
* {n}
*/
"nRequests": ParameterizedString<"n">;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -2207,6 +2207,7 @@ _widgets:
chooseList: "リストを選択" chooseList: "リストを選択"
clicker: "クリッカー" clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー" birthdayFollowings: "今日誕生日のユーザー"
birthdaySoon: "もうすぐ誕生日のユーザー"
_cw: _cw:
hide: "隠す" hide: "隠す"
@ -2692,3 +2693,19 @@ _urlPreviewSetting:
summaryProxy: "プレビューを生成するプロキシのエンドポイント" summaryProxy: "プレビューを生成するプロキシのエンドポイント"
summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。" summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。" summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
_skebStatus:
_genres:
art: "イラスト"
comic: "コミック"
voice: "ボイス"
novel: "テキスト"
video: "ムービー"
music: "ミュージック"
correction: "アドバイス"
seeking: "募集中"
stopped: "停止中"
client: "クライアント"
yenX: "{x}円"
nWorks: "納品実績 {n}件"
nRequests: "取引実績 {n}件"

View file

@ -2131,6 +2131,7 @@ _widgets:
chooseList: "리스트 선택" chooseList: "리스트 선택"
clicker: "클리커" clicker: "클리커"
birthdayFollowings: "오늘이 생일인 사용자" birthdayFollowings: "오늘이 생일인 사용자"
birthdaySoon: "곧 생일인 사용자"
_cw: _cw:
hide: "숨기기" hide: "숨기기"
show: "더 보기" show: "더 보기"
@ -2548,4 +2549,18 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "오프라인 - 서버에 접속할 수 없습니다" title: "오프라인 - 서버에 접속할 수 없습니다"
header: "서버에 접속할 수 없습니다" header: "서버에 접속할 수 없습니다"
_skebStatus:
_genres:
art: "작품"
comic: "만화"
voice: "음성"
novel: "텍스트"
video: "동영상"
music: "음악"
correction: "조언"
seeking: "모집 중"
stopped: "정지 중"
client: "클라이언트"
yenX: "JPY {x}"
nWorks: "납품 실적 {n}건"
nRequests: "거래 실적 {n}건"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.3.1-host.3", "version": "2024.3.1-host.4",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -0,0 +1,15 @@
export class BirthdayIndex1711478468155 {
name = 'BirthdayIndex1711478468155'
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

@ -81,6 +81,14 @@ type Source = {
} }
}; };
skebStatus?: {
method: string;
endpoint: string;
headers: { [x: string]: string };
parameters: { [x: string]: string };
userIdParameterName: string;
}
proxy?: string; proxy?: string;
proxySmtp?: string; proxySmtp?: string;
proxyBypassHosts?: string[]; proxyBypassHosts?: string[];
@ -115,6 +123,7 @@ type Source = {
bypassRateLimit?: { header: string; value: string }[]; bypassRateLimit?: { header: string; value: string }[];
remapDriveFileUrlForActivityPub?: { target: string; replacement: string }[];
signToActivityPubGet?: boolean; signToActivityPubGet?: boolean;
perChannelMaxNoteCacheCount?: number; perChannelMaxNoteCacheCount?: number;
@ -169,6 +178,13 @@ export type Config = {
useProxy?: boolean; useProxy?: boolean;
} }
} | undefined; } | undefined;
skebStatus: {
method: string;
endpoint: string;
headers: { [x: string]: string };
parameters: { [x: string]: string };
userIdParameterName: string;
} | undefined;
proxy: string | undefined; proxy: string | undefined;
proxySmtp: string | undefined; proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined; proxyBypassHosts: string[] | undefined;
@ -190,6 +206,7 @@ export type Config = {
deliverJobMaxAttempts: number | undefined; deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined; inboxJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined; proxyRemoteFiles: boolean | undefined;
remapDriveFileUrlForActivityPub: { target: string; replacement: string }[] | undefined;
signToActivityPubGet: boolean | undefined; signToActivityPubGet: boolean | undefined;
version: string; version: string;
@ -295,6 +312,7 @@ export function loadConfig(): Config {
redisForObjectStorageQueue: config.redisForObjectStorageQueue ? convertRedisOptions(config.redisForObjectStorageQueue, host) : redisForJobQueue, redisForObjectStorageQueue: config.redisForObjectStorageQueue ? convertRedisOptions(config.redisForObjectStorageQueue, host) : redisForJobQueue,
redisForWebhookDeliverQueue: config.redisForWebhookDeliverQueue ? convertRedisOptions(config.redisForWebhookDeliverQueue, host) : redisForJobQueue, redisForWebhookDeliverQueue: config.redisForWebhookDeliverQueue ? convertRedisOptions(config.redisForWebhookDeliverQueue, host) : redisForJobQueue,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
skebStatus: config.skebStatus,
id: config.id, id: config.id,
proxy: config.proxy, proxy: config.proxy,
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,
@ -316,6 +334,7 @@ export function loadConfig(): Config {
deliverJobMaxAttempts: config.deliverJobMaxAttempts, deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts, inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles, proxyRemoteFiles: config.proxyRemoteFiles,
remapDriveFileUrlForActivityPub: config.remapDriveFileUrlForActivityPub,
signToActivityPubGet: config.signToActivityPubGet, signToActivityPubGet: config.signToActivityPubGet,
mediaProxy: externalMediaProxy ?? internalMediaProxy, mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,

View file

@ -56,8 +56,10 @@ export class HashtagService {
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) { public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
tag = normalizeForSearch(tag); tag = normalizeForSearch(tag);
// TODO: サンプリング if (this.userEntityService.isLocalUser(user)) {
this.updateHashtagsRanking(tag, user.id); // TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
}
const index = await this.hashtagsRepository.findOneBy({ name: tag }); const index = await this.hashtagsRepository.findOneBy({ name: tag });

View file

@ -164,13 +164,20 @@ export class ApRendererService {
return { return {
type: 'Document', type: 'Document',
mediaType: file.webpublicType ?? file.type, mediaType: file.webpublicType ?? file.type,
url: this.driveFileEntityService.getPublicUrl(file), url: this.driveFileEntityService.getPublicUrl(file, { remapActivityPub: true }),
name: file.comment, name: file.comment,
}; };
} }
@bindThis @bindThis
public renderEmoji(emoji: MiEmoji): IApEmoji { public renderEmoji(emoji: MiEmoji): IApEmoji {
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
let url = emoji.publicUrl || emoji.originalUrl;
this.config.remapDriveFileUrlForActivityPub?.forEach(({ target, replacement }) => {
url = url.replace(target, replacement);
});
return { return {
id: `${this.config.url}/emojis/${emoji.name}`, id: `${this.config.url}/emojis/${emoji.name}`,
type: 'Emoji', type: 'Emoji',
@ -179,8 +186,7 @@ export class ApRendererService {
icon: { icon: {
type: 'Image', type: 'Image',
mediaType: emoji.type ?? 'image/png', mediaType: emoji.type ?? 'image/png',
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) url: url,
url: emoji.publicUrl || emoji.originalUrl,
}, },
}; };
} }
@ -243,7 +249,7 @@ export class ApRendererService {
public renderImage(file: MiDriveFile): IApImage { public renderImage(file: MiDriveFile): IApImage {
return { return {
type: 'Image', type: 'Image',
url: this.driveFileEntityService.getPublicUrl(file), url: this.driveFileEntityService.getPublicUrl(file, { remapActivityPub: true }),
sensitive: file.isSensitive, sensitive: file.isSensitive,
name: file.comment, name: file.comment,
}; };

View file

@ -267,12 +267,12 @@ export class ApPersonService implements OnModuleInit {
return { return {
...( avatar ? { ...( avatar ? {
avatarId: avatar.id, avatarId: avatar.id,
avatarUrl: avatar.url ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null, avatarUrl: avatar.url ? this.driveFileEntityService.getPublicUrl(avatar, { mode: 'avatar', remapActivityPub: true }) : null,
avatarBlurhash: avatar.blurhash, avatarBlurhash: avatar.blurhash,
} : {}), } : {}),
...( banner ? { ...( banner ? {
bannerId: banner.id, bannerId: banner.id,
bannerUrl: banner.url ? this.driveFileEntityService.getPublicUrl(banner) : null, bannerUrl: banner.url ? this.driveFileEntityService.getPublicUrl(banner, { remapActivityPub: true }) : null,
bannerBlurhash: banner.blurhash, bannerBlurhash: banner.blurhash,
} : {}), } : {}),
}; };

View file

@ -109,10 +109,10 @@ export class DriveFileEntityService {
} }
@bindThis @bindThis
public getPublicUrl(file: MiDriveFile, mode?: 'avatar'): string { // static = thumbnail public getPublicUrl(file: MiDriveFile, option?: { mode?: 'avatar', remapActivityPub?: boolean }): string { // static = thumbnail
// リモートかつメディアプロキシ // リモートかつメディアプロキシ
if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
return this.getProxiedUrl(file.uri, mode); return this.getProxiedUrl(file.uri, option?.mode);
} }
// リモートかつ期限切れはローカルプロキシを試みる // リモートかつ期限切れはローカルプロキシを試みる
@ -121,17 +121,23 @@ export class DriveFileEntityService {
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
const url = `${this.config.url}/files/${key}`; const url = `${this.config.url}/files/${key}`;
if (mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar'); if (option?.mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar');
return url; return url;
} }
} }
const url = file.webpublicUrl ?? file.url; let publicUrl = file.webpublicUrl ?? file.url;
if (option?.remapActivityPub) {
if (mode === 'avatar') { this.config.remapDriveFileUrlForActivityPub?.forEach(({ target, replacement }) => {
return this.getProxiedUrl(url, 'avatar'); publicUrl = publicUrl.replace(target, replacement);
});
} }
return url;
const url = new URL(publicUrl);
if (file.isSensitive) url.searchParams.set('sensitive', 'true');
if (option?.mode === 'avatar') return this.getProxiedUrl(url.href, 'avatar');
else return url.href;
} }
@bindThis @bindThis

View file

@ -112,6 +112,7 @@ export class MetaEntityService {
mediaProxy: this.config.mediaProxy, mediaProxy: this.config.mediaProxy,
enableUrlPreview: instance.urlPreviewEnabled, enableUrlPreview: instance.urlPreviewEnabled,
enableSkebStatus: !!this.config.skebStatus,
}; };
} }

View file

@ -642,8 +642,8 @@ export class UserEntityService implements OnModuleInit {
// -- 特に前提条件のない値群を取得 // -- 特に前提条件のない値群を取得
const profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) const profilesMap = (options?.schema !== 'UserLite') ? await this.userProfilesRepository.findBy({ userId: In(_userIds) })
.then(profiles => new Map(profiles.map(p => [p.userId, p]))); .then(profiles => new Map(profiles.map(p => [p.userId, p]))) : undefined;
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
@ -680,7 +680,7 @@ export class UserEntityService implements OnModuleInit {
} }
} }
return (await Promise.allSettled(_users.map(u => this.pack(u, me, { ...options, userProfile: profilesMap.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes })))) return (await Promise.allSettled(_users.map(u => this.pack(u, me, { ...options, userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes }))))
.filter(result => result.status === 'fulfilled') .filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<Packed<S>>).value); .map(result => (result as PromiseFulfilledResult<Packed<S>>).value);
} }

View file

@ -216,6 +216,10 @@ export const packedMetaLiteSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
enableSkebStatus: {
type: 'boolean',
optional: false, nullable: false,
},
backgroundImageUrl: { backgroundImageUrl: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,

View file

@ -348,7 +348,9 @@ import * as ep___users_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.js'; import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFollowingBirthdayUsers from './endpoints/users/get-following-birthday-users.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_getSkebStatus from './endpoints/users/get-skeb-status.js';
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js';
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
@ -733,7 +735,9 @@ const $users_clips: Provider = { provide: 'ep:users/clips', useClass: ep___users
const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default }; const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default };
const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default };
const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
const $users_getFollowingBirthdayUsers: Provider = { provide: 'ep:users/get-following-birthday-users', useClass: ep___users_getFollowingBirthdayUsers.default };
const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
const $users_getSkebStatus: Provider = { provide: 'ep:users/get-skeb-status', useClass: ep___users_getSkebStatus.default };
const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default }; const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default };
@ -1122,7 +1126,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$users_followers, $users_followers,
$users_following, $users_following,
$users_gallery_posts, $users_gallery_posts,
$users_getFollowingBirthdayUsers,
$users_getFrequentlyRepliedUsers, $users_getFrequentlyRepliedUsers,
$users_getSkebStatus,
$users_featuredNotes, $users_featuredNotes,
$users_lists_create, $users_lists_create,
$users_lists_delete, $users_lists_delete,
@ -1503,7 +1509,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$users_followers, $users_followers,
$users_following, $users_following,
$users_gallery_posts, $users_gallery_posts,
$users_getFollowingBirthdayUsers,
$users_getFrequentlyRepliedUsers, $users_getFrequentlyRepliedUsers,
$users_getSkebStatus,
$users_featuredNotes, $users_featuredNotes,
$users_lists_create, $users_lists_create,
$users_lists_delete, $users_lists_delete,

View file

@ -348,7 +348,9 @@ import * as ep___users_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.js'; import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFollowingBirthdayUsers from './endpoints/users/get-following-birthday-users.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_getSkebStatus from './endpoints/users/get-skeb-status.js';
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js';
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
@ -731,7 +733,9 @@ const eps = [
['users/followers', ep___users_followers], ['users/followers', ep___users_followers],
['users/following', ep___users_following], ['users/following', ep___users_following],
['users/gallery/posts', ep___users_gallery_posts], ['users/gallery/posts', ep___users_gallery_posts],
['users/get-following-birthday-users', ep___users_getFollowingBirthdayUsers],
['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers], ['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
['users/get-skeb-status', ep___users_getSkebStatus],
['users/featured-notes', ep___users_featuredNotes], ['users/featured-notes', ep___users_featuredNotes],
['users/lists/create', ep___users_lists_create], ['users/lists/create', ep___users_lists_create],
['users/lists/delete', ep___users_lists_delete], ['users/lists/delete', ep___users_lists_delete],

View file

@ -314,7 +314,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage);
updates.avatarId = avatar.id; updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, { mode: 'avatar' });
updates.avatarBlurhash = avatar.blurhash; updates.avatarBlurhash = avatar.blurhash;
} else if (ps.avatarId === null) { } else if (ps.avatarId === null) {
updates.avatarId = null; updates.avatarId = null;

View file

@ -6,6 +6,7 @@
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js'; import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js';
import { birthdaySchema } from '@/models/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
@ -66,7 +67,10 @@ export const paramDef = {
description: 'The local host is represented with `null`.', description: 'The local host is represented with `null`.',
}, },
birthday: { type: 'string', nullable: true }, birthday: {
...birthdaySchema, nullable: true,
description: '@deprecated use get-following-birthday-users instead.',
},
}, },
anyOf: [ anyOf: [
{ required: ['userId'] }, { required: ['userId'] },
@ -125,16 +129,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id }) .andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee'); .innerJoinAndSelect('following.followee', 'followee');
// @deprecated use get-following-birthday-users instead.
if (ps.birthday) { if (ps.birthday) {
try { query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
const d = new Date(ps.birthday);
d.setHours(0, 0, 0, 0);
const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
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) { } catch (err) {
throw new ApiError(meta.errors.birthdayInvalid); throw new ApiError(meta.errors.birthdayInvalid);
} }

View file

@ -0,0 +1,130 @@
import { Inject, Injectable } from '@nestjs/common';
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: {
birthday: {
type: 'string', format: 'date-time',
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: {
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
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'],
},
},
anyOf: [
{ 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 { begin, end } = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin: begin.month * 100 + begin.day, end: end.month * 100 + end.day });
} 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.setMonth(Math.floor(item.birthday_date / 100) - 1);
birthday.setDate(item.birthday_date % 100);
birthday.setHours(0, 0, 0, 0);
if (birthday.getTime() < Date.now()) birthday.setFullYear(new Date().getFullYear() + 1);
return { birthday: birthday.toISOString(), user: users.get(item.user_id) };
})
.filter(item => item.user !== undefined)
.map(item => item as { birthday: string; user: Packed<'UserLite'> });
});
}
}

View file

@ -0,0 +1,148 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type { Config } from '@/config.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['users'],
requireCredential: false,
allowGet: true,
cacheSec: 60 * 5,
res: {
type: 'object',
properties: {
screenName: {
type: 'string',
optional: false, nullable: false,
},
isCreator: {
type: 'boolean',
optional: false, nullable: false,
},
isAcceptable: {
type: 'boolean',
optional: false, nullable: false,
},
creatorRequestCount: {
type: 'integer',
optional: false, nullable: false,
},
clientRequestCount: {
type: 'integer',
optional: false, nullable: false,
},
skills: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
properties: {
amount: {
type: 'integer',
optional: false, nullable: false,
},
genre: {
type: 'string',
optional: false, nullable: false,
enum: ['art', 'comic', 'voice', 'novel', 'video', 'music', 'correction'],
},
},
},
},
},
},
errors: {
skebStatusNotAvailable: {
message: 'Skeb status is not available.',
code: 'SKEB_STATUS_NOT_AVAILABLE',
id: '1dd37c9c-7e97-4c24-be98-227a78b21d80',
httpStatusCode: 403,
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '88d582ae-69d9-45e0-a8b3-13f9945e48bf',
httpStatusCode: 404,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.config)
private config: Config,
private loggerService: LoggerService,
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps) => {
if (!this.config.skebStatus) throw new ApiError(meta.errors.skebStatusNotAvailable);
const logger = this.loggerService.getLogger('api:users:get-skeb-status');
const url = new URL(this.config.skebStatus.endpoint);
for (const [key, value] of Object.entries(this.config.skebStatus.parameters)) {
url.searchParams.set(key, value);
}
url.searchParams.set(this.config.skebStatus.userIdParameterName, ps.userId);
logger.info('Requesting Skeb status', { url: url.href, userId: ps.userId });
const res = await this.httpRequestService.send(
url.href,
{
method: this.config.skebStatus.method,
headers: {
...this.config.skebStatus.headers,
Accept: 'application/json',
},
timeout: 5000,
},
{
throwErrorWhenResponseNotOk: false,
},
);
const json = (await res.json()) as {
screen_name: string,
is_creator: boolean,
is_acceptable: boolean,
creator_request_count: number,
client_request_count: number,
skills: { amount: number, genre: 'art' | 'comic' | 'voice' | 'novel' | 'video' | 'music' | 'correction' }[],
ban_reason?: string | null
error?: unknown,
};
if (res.status > 399 || (json.error ?? json.ban_reason)) {
logger.error('Skeb status response error', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, error: json.error ?? json.ban_reason });
throw new ApiError(meta.errors.noSuchUser);
}
logger.info('Skeb status response', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, skebStatus: json });
return {
screenName: json.screen_name,
isCreator: json.is_creator,
isAcceptable: json.is_acceptable,
creatorRequestCount: json.creator_request_count,
clientRequestCount: json.client_request_count,
skills: json.skills,
};
});
}
}

View file

@ -27,10 +27,21 @@ export const meta = {
res: { res: {
optional: false, nullable: false, optional: false, nullable: false,
oneOf: [ oneOf: [
{
type: 'object',
ref: 'UserLite',
},
{ {
type: 'object', type: 'object',
ref: 'UserDetailed', ref: 'UserDetailed',
}, },
{
type: 'array',
items: {
type: 'object',
ref: 'UserLite',
},
},
{ {
type: 'array', type: 'array',
items: { items: {
@ -71,6 +82,7 @@ export const paramDef = {
nullable: true, nullable: true,
description: 'The local host is represented with `null`.', description: 'The local host is represented with `null`.',
}, },
detailed: { type: 'boolean', default: true },
}, },
anyOf: [ anyOf: [
{ required: ['userId'] }, { required: ['userId'] },
@ -117,7 +129,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
return await this.userEntityService.packMany(_users, me, { return await this.userEntityService.packMany(_users, me, {
schema: 'UserDetailed', schema: ps.detailed ? 'UserDetailed' : 'UserLite',
}); });
} else { } else {
// Lookup user // Lookup user
@ -147,7 +159,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
return await this.userEntityService.pack(user, me, { return await this.userEntityService.pack(user, me, {
schema: 'UserDetailed', schema: ps.detailed ? 'UserDetailed' : 'UserLite',
}); });
} }
}); });

View file

@ -93,7 +93,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1);
const info = { const info = {
operationId: endpoint.name, operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない
summary: endpoint.name, summary: endpoint.name,
description: desc, description: desc,
externalDocs: { externalDocs: {

View file

@ -160,19 +160,17 @@ describe('Streaming', () => {
assert.strictEqual(fired, true); assert.strictEqual(fired, true);
}); });
/*
test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => { test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => {
const note = await api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko); const note = await post(kyoko, { text: 'foo', visibility: 'followers' });
const fired = await waitFire( const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.body.id }, kyoko), // kyoko posts () => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.id }, kyoko), // kyoko posts
msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo', msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo',
); );
assert.strictEqual(fired, true); assert.strictEqual(fired, true);
}); });
*/
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => { test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' }); const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });

View file

@ -29,6 +29,7 @@ const users = ref<Misskey.entities.UserLite[]>([]);
onMounted(async () => { onMounted(async () => {
users.value = await misskeyApi('users/show', { users.value = await misskeyApi('users/show', {
userIds: props.userIds, userIds: props.userIds,
detailed: false,
}) as unknown as Misskey.entities.UserLite[]; }) as unknown as Misskey.entities.UserLite[];
}); });
</script> </script>

View file

@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd> <dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd>
</dl> </dl>
</div> </div>
<div v-if="user.fields.length > 0" class="fields"> <div v-if="user.fields.length > 0 || userSkebStatus" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field"> <dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name"> <dt class="name">
<Mfm :text="field.name" :plain="true" :colored="false"/> <Mfm :text="field.name" :plain="true" :colored="false"/>
@ -104,6 +104,44 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
</dd> </dd>
</dl> </dl>
<dl v-if="userSkebStatus" class="field">
<dt class="name">
<a href="https://skeb.jp/" target="_blank" rel="noopener" style="display: flex; gap: 2px; align-items: center; justify-content: center;">
<!--
*** LICENSE NOTICE ***
* This SVG is derived from the https://skeb.jp/ website, All rights reserved to *Skeb Inc.* https://skeb.co.jp/
* This resource SHOULD NOT be considered as a part of the this project that has licensed under AGPL-3.0-only
-->
<svg class="ti ti-fw" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<linearGradient id="a" x1="14.645309%" x2="85.354691%" y1="14.645309%" y2="85.363492%">
<stop offset=".2" stop-color="#30b396"/>
<stop offset="1" stop-color="#1e5e71"/>
</linearGradient>
<g fill="none">
<circle cx="19.99375" cy="19.98125" fill="url(#a)" r="17.753125"/>
<path d="m18.784375 13.9875-7.35625 21.546875c-.8125-.446875-1.58125-.959375-2.309375-1.525l1.134375-3.26875-2.528125-.86875.859375-2.56875 2.528125.86875 1.728125-5.05625-2.528125-.86875.86875-2.5375 2.528125.86875 1.728125-5.05625-2.528125-.86875.86875-2.5375 2.528125.86875 1.6875-4.99375-2.496875-.909375.440625-1.26875 5.05625 1.728125-.378125 1.1-1.76875 5.184375-.059375.159375zm-12.996875 16.640625c.6625.884375 1.40625 1.703125 2.21875 2.446875l.51875-1.515625zm3.29375-13.01875 3.8 1.296875.865625-2.534375-3.8-1.296875zm-2.590625 7.590625 3.8 1.296875.865625-2.534375-3.8-1.296875zm31.259375-5.21875c0-2.534375-.534375-4.940625-1.490625-7.11875l-13.325-4.559375-9.603125 28.134375c2.059375.834375 4.30625 1.3 6.665625 1.3 9.80625 0 17.753125-7.946875 17.753125-17.753125zm-26.071875-9.971875 3.8 1.296875.865625-2.534375-3.8-1.296875z" fill="#fff"/><path d="m19.99375 2.23125c9.80625 0 17.753125 7.946875 17.753125 17.753125s-7.946875 17.753125-17.753125 17.753125-17.753125-7.946875-17.753125-17.753125 7.946875-17.753125 17.753125-17.753125m0-2.228125c-11.034375 0-19.98125 8.946875-19.98125 19.98125s8.946875 19.98125 19.98125 19.98125 19.98125-8.946875 19.98125-19.98125-8.946875-19.98125-19.98125-19.98125z" fill="#1e5e71"/>
</g>
</svg>
<span>Skeb</span>
<i class="ti ti-external-link" style="font-size: .9em;"></i>
</a>
</dt>
<dd class="value">
<a :href="`https://skeb.jp/@${userSkebStatus.screenName}`" target="_blank" rel="noopener" style="display: flex; gap: 2px; align-items: center;">
<span v-if="userSkebStatus.isAcceptable" :class="$style.skebAcceptable">
{{ i18n.ts._skebStatus.seeking }}
</span>
<span v-else-if="userSkebStatus.isCreator" :class="$style.skebStopped">
{{ i18n.ts._skebStatus.stopped }}
</span>
<span v-else :class="$style.skebClient">
{{ i18n.ts._skebStatus.client }}
</span>
<Mfm :text="buildSkebStatus()" :author="user" :nyaize="false" :colored="false"/>
<i class="ti ti-external-link" style="font-size: .9em;"></i>
</a>
</dd>
</dl>
</div> </div>
<div class="status"> <div class="status">
<MkA :to="userPage(user)"> <MkA :to="userPage(user)">
@ -167,10 +205,11 @@ import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { $i, iAmModerator } from '@/account.js'; import { $i, iAmModerator } from '@/account.js';
import { dateString } from '@/filters/date.js'; import { dateString } from '@/filters/date.js';
import { confetti } from '@/scripts/confetti.js'; import { confetti } from '@/scripts/confetti.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
@ -204,6 +243,7 @@ const props = withDefaults(defineProps<{
const router = useRouter(); const router = useRouter();
const user = ref(props.user); const user = ref(props.user);
const userSkebStatus = ref<Misskey.Endpoints['users/get-skeb-status']['res'] | null>(null);
const parallaxAnimationId = ref<null | number>(null); const parallaxAnimationId = ref<null | number>(null);
const narrow = ref<null | boolean>(null); const narrow = ref<null | boolean>(null);
const rootEl = ref<null | HTMLElement>(null); const rootEl = ref<null | HTMLElement>(null);
@ -273,8 +313,44 @@ async function updateMemo() {
isEditingMemo.value = false; isEditingMemo.value = false;
} }
async function fetchSkebStatus() {
if (!instance.enableSkebStatus || !props.user.id) {
userSkebStatus.value = null;
return;
}
console.log('fetching skeb status');
userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.user.id });
}
function buildSkebStatus(): string {
if (!userSkebStatus.value) return '';
if (userSkebStatus.value.isCreator) {
let status = '';
if (userSkebStatus.value.isAcceptable) {
status += `${i18n.ts._skebStatus._genres[userSkebStatus.value.skills[0].genre]} ${i18n.tsx._skebStatus.yenX({ x: userSkebStatus.value.skills[0].amount.toLocaleString() })}`;
}
if (userSkebStatus.value.creatorRequestCount > 0) {
if (userSkebStatus.value.isAcceptable) {
status += ' | ';
}
status += i18n.tsx._skebStatus.nWorks({ n: userSkebStatus.value.creatorRequestCount.toLocaleString() });
}
return status;
} else if (userSkebStatus.value.clientRequestCount > 0) {
return i18n.tsx._skebStatus.nRequests({ n: userSkebStatus.value.clientRequestCount.toLocaleString() });
}
return '';
}
watch([props.user], () => { watch([props.user], () => {
memoDraft.value = props.user.memo; memoDraft.value = props.user.memo;
fetchSkebStatus();
}); });
onMounted(() => { onMounted(() => {
@ -292,6 +368,7 @@ onMounted(() => {
}); });
} }
} }
fetchSkebStatus();
nextTick(() => { nextTick(() => {
adjustMemoTextarea(); adjustMemoTextarea();
}); });
@ -685,4 +762,30 @@ onUnmounted(() => {
margin-left: 4px; margin-left: 4px;
color: var(--success); color: var(--success);
} }
.skebAcceptable,
.skebStopped,
.skebClient {
display: inline-flex;
border: solid 1px;
border-radius: 6px;
padding: 2px 6px;
margin-right: 4px;
font-size: 85%;
}
.skebAcceptable {
color: rgb(255, 255, 255);
background-color: rgb(241, 70, 104);
}
.skebStopped {
color: rgb(255, 255, 255);
background-color: rgb(54, 54, 54);
}
.skebClient {
color: rgb(255, 255, 255);
background-color: rgb(54, 54, 54);
}
</style> </style>

View file

@ -4,42 +4,76 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <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 #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template> <template v-if="widgetProps.period === 'today'" #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
<template v-else #header>{{ i18n.ts._widgets.birthdaySoon }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="fetch(true)"><i class="ti ti-refresh"></i></button></template>
<div :class="$style.bdayFRoot"> <MkPagination ref="paginationEl" :pagination="birthdayUsersPagination">
<MkLoading v-if="fetching"/> <template #empty>
<div v-else-if="users.length > 0" :class="$style.bdayFGrid"> <div :class="$style.empty" :style="`height: ${widgetProps.showHeader ? widgetProps.height - 38 : widgetProps.height}px;`">
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar> <img :src="infoImageUrl" class="_ghost"/>
</div> <div>{{ i18n.ts.nothing }}</div>
<div v-else :class="$style.bdayFFallback"> </div>
<img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/> </template>
<div>{{ i18n.ts.nothing }}</div>
</div> <template #default="{ items: users }">
</div> <MkDateSeparatedList v-slot="{ item }" :items="toMisskeyEntity(users)" :noGap="true">
<div v-if="item.user" :key="item.id" style="display: flex; gap: 8px; padding-right: 16px">
<MkA :to="userPage(item.user)" style="flex-grow: 1;">
<MkUserCardMini :user="item.user" :withChart="false" style="background: inherit; border-radius: unset;"/>
</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>
</MkDateSeparatedList>
</template>
</MkPagination>
</MkContainer> </MkContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import { GetFormResultType } from '@/scripts/form.js'; import type { MisskeyEntity } from '@/types/date-separated-list.js';
import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useInterval } from '@/scripts/use-interval.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { userPage } from '@/filters/user.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
import { $i } from '@/account.js'; import { GetFormResultType } from '@/scripts/form.js';
import { useInterval } from '@/scripts/use-interval.js';
import MkContainer from '@/components/MkContainer.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
const name = i18n.ts._widgets.birthdayFollowings; const name = i18n.ts._widgets.birthdaySoon;
const widgetPropsDef = { const widgetPropsDef = {
showHeader: { showHeader: {
type: 'boolean' as const, type: 'boolean' as const,
default: true, default: true,
}, },
height: {
type: 'number' as const,
default: 300,
},
period: {
type: 'radio' as const,
default: 'today',
options: [{
value: 'today', label: i18n.ts.today,
}, {
value: '3day', label: i18n.tsx.dayX({ day: 3 }),
}, {
value: 'week', label: i18n.ts.oneWeek,
}, {
value: 'month', label: i18n.ts.oneMonth,
}],
},
}; };
type WidgetProps = GetFormResultType<typeof widgetPropsDef>; type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
@ -47,42 +81,75 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>(); const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name, const { widgetProps, configure } = useWidgetPropsManager(
name,
widgetPropsDef, widgetPropsDef,
props, props,
emit, emit,
); );
const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]); const begin = ref<Date>(new Date());
const fetching = ref(true); const end = computed(() => {
let lastFetchedAt = '1970-01-01'; switch (widgetProps.period) {
case '3day':
const fetch = () => { return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 3);
if (!$i) { case 'week':
users.value = []; return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 7);
fetching.value = false; case 'month':
return; return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 30);
default:
return begin.value;
} }
});
const lfAtD = new Date(lastFetchedAt); const paginationEl = ref<InstanceType<typeof MkPagination>>();
lfAtD.setHours(0, 0, 0, 0); const birthdayUsersPagination = {
const now = new Date(); endpoint: 'users/get-following-birthday-users' as const,
now.setHours(0, 0, 0, 0); limit: 18,
offsetMode: true,
if (now > lfAtD) { params: computed(() => {
misskeyApi('users/following', { if (widgetProps.period === 'today') {
limit: 18, return {
birthday: now.toISOString(), birthday: {
userId: $i.id, month: begin.value.getMonth() + 1,
}).then(res => { day: begin.value.getDate(),
users.value = res; },
fetching.value = false; };
}); } else {
return {
lastFetchedAt = now.toISOString(); birthday: {
} begin: {
month: begin.value.getMonth() + 1,
day: begin.value.getDate(),
},
end: {
month: end.value.getMonth() + 1,
day: end.value.getDate(),
},
},
};
}
}),
}; };
function fetch(force = false) {
const now = new Date();
if (force || now.getDate() !== begin.value.getDate()) {
// computed() paginationEl.value!.reload()
begin.value = now;
}
}
function toMisskeyEntity(items): MisskeyEntity[] {
const r = items.map((item: { userId: string, birthday: string, user: Misskey.entities.UserLite }) => ({
id: item.user.id,
createdAt: item.birthday,
user: item.user,
}));
return [{ id: '_', createdAt: begin.value.toISOString() }, ...r];
}
useInterval(fetch, 1000 * 60, { useInterval(fetch, 1000 * 60, {
immediate: true, immediate: true,
afterMounted: true, afterMounted: true,
@ -96,32 +163,39 @@ defineExpose<WidgetComponentExpose>({
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.bdayFRoot { .empty {
overflow: hidden;
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--margin) * 2));
}
.bdayFGrid {
display: grid;
grid-template-columns: repeat(6, 42px);
grid-template-rows: repeat(3, 42px);
place-content: center;
gap: 8px;
margin: var(--margin) auto;
}
.bdayFFallback {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
> img {
height: 96px;
width: auto;
max-width: 90%;
margin-bottom: 8px;
border-radius: var(--radius);
}
} }
.bdayFFallbackImage { .post {
height: 96px; display: flex;
width: auto; justify-content: center;
max-width: 90%; align-items: center;
margin-bottom: 8px; height: 40px;
border-radius: var(--radius); margin: auto;
aspect-ratio: 1/1;
border-radius: 100%;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
&:hover, &.active {
&:before {
background: var(--accentLighten);
}
}
}
.postIcon {
color: var(--fgOnAccent);
} }
</style> </style>

File diff suppressed because it is too large Load diff

View file

@ -60,13 +60,17 @@ async function generateEndpoints(
// misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり // misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり
const paths = openApiDocs.paths ?? {}; const paths = openApiDocs.paths ?? {};
const postPathItems = Object.keys(paths) const postPathItems = Object.keys(paths)
.map(it => paths[it]?.post) .map(it => ({
_path_: it.replace(/^\//, ''),
...paths[it]?.post,
}))
.filter(filterUndefined); .filter(filterUndefined);
for (const operation of postPathItems) { for (const operation of postPathItems) {
const path = operation._path_;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const operationId = operation.operationId!; const operationId = operation.operationId!;
const endpoint = new Endpoint(operationId); const endpoint = new Endpoint(path);
endpoints.push(endpoint); endpoints.push(endpoint);
if (isRequestBodyObject(operation.requestBody)) { if (isRequestBodyObject(operation.requestBody)) {
@ -76,19 +80,21 @@ async function generateEndpoints(
// いまのところ複数のメディアタイプをとるエンドポイントは無いので決め打ちする // いまのところ複数のメディアタイプをとるエンドポイントは無いので決め打ちする
endpoint.request = new OperationTypeAlias( endpoint.request = new OperationTypeAlias(
operationId, operationId,
path,
supportMediaTypes[0], supportMediaTypes[0],
OperationsAliasType.REQUEST, OperationsAliasType.REQUEST,
); );
} }
} }
if (isResponseObject(operation.responses['200']) && operation.responses['200'].content) { if (operation.responses && isResponseObject(operation.responses['200']) && operation.responses['200'].content) {
const resContent = operation.responses['200'].content; const resContent = operation.responses['200'].content;
const supportMediaTypes = Object.keys(resContent); const supportMediaTypes = Object.keys(resContent);
if (supportMediaTypes.length > 0) { if (supportMediaTypes.length > 0) {
// いまのところ複数のメディアタイプを返すエンドポイントは無いので決め打ちする // いまのところ複数のメディアタイプを返すエンドポイントは無いので決め打ちする
endpoint.response = new OperationTypeAlias( endpoint.response = new OperationTypeAlias(
operationId, operationId,
path,
supportMediaTypes[0], supportMediaTypes[0],
OperationsAliasType.RESPONSE, OperationsAliasType.RESPONSE,
); );
@ -98,6 +104,8 @@ async function generateEndpoints(
const entitiesOutputLine: string[] = []; const entitiesOutputLine: string[] = [];
entitiesOutputLine.push('/* eslint @typescript-eslint/naming-convention: 0 */');
entitiesOutputLine.push(`import { operations } from '${toImportPath(typeFileName)}';`); entitiesOutputLine.push(`import { operations } from '${toImportPath(typeFileName)}';`);
entitiesOutputLine.push(''); entitiesOutputLine.push('');
@ -138,12 +146,19 @@ async function generateApiClientJSDoc(
endpointsFileName: string, endpointsFileName: string,
warningsOutputPath: string, warningsOutputPath: string,
) { ) {
const endpoints: { operationId: string; description: string; }[] = []; const endpoints: {
operationId: string;
path: string;
description: string;
}[] = [];
// misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり // misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり
const paths = openApiDocs.paths ?? {}; const paths = openApiDocs.paths ?? {};
const postPathItems = Object.keys(paths) const postPathItems = Object.keys(paths)
.map(it => paths[it]?.post) .map(it => ({
_path_: it.replace(/^\//, ''),
...paths[it]?.post,
}))
.filter(filterUndefined); .filter(filterUndefined);
for (const operation of postPathItems) { for (const operation of postPathItems) {
@ -153,6 +168,7 @@ async function generateApiClientJSDoc(
if (operation.description) { if (operation.description) {
endpoints.push({ endpoints.push({
operationId: operationId, operationId: operationId,
path: operation._path_,
description: operation.description, description: operation.description,
}); });
} }
@ -173,7 +189,7 @@ async function generateApiClientJSDoc(
' /**', ' /**',
` * ${endpoint.description.split('\n').join('\n * ')}`, ` * ${endpoint.description.split('\n').join('\n * ')}`,
' */', ' */',
` request<E extends '${endpoint.operationId}', P extends Endpoints[E][\'req\']>(`, ` request<E extends '${endpoint.path}', P extends Endpoints[E][\'req\']>(`,
' endpoint: E,', ' endpoint: E,',
' params: P,', ' params: P,',
' credential?: string | null,', ' credential?: string | null,',
@ -232,21 +248,24 @@ interface IOperationTypeAlias {
class OperationTypeAlias implements IOperationTypeAlias { class OperationTypeAlias implements IOperationTypeAlias {
public readonly operationId: string; public readonly operationId: string;
public readonly path: string;
public readonly mediaType: string; public readonly mediaType: string;
public readonly type: OperationsAliasType; public readonly type: OperationsAliasType;
constructor( constructor(
operationId: string, operationId: string,
path: string,
mediaType: string, mediaType: string,
type: OperationsAliasType, type: OperationsAliasType,
) { ) {
this.operationId = operationId; this.operationId = operationId;
this.path = path;
this.mediaType = mediaType; this.mediaType = mediaType;
this.type = type; this.type = type;
} }
generateName(): string { generateName(): string {
const nameBase = this.operationId.replace(/\//g, '-'); const nameBase = this.path.replace(/\//g, '-');
return toPascal(nameBase + this.type); return toPascal(nameBase + this.type);
} }
@ -279,19 +298,19 @@ const emptyRequest = new EmptyTypeAlias(OperationsAliasType.REQUEST);
const emptyResponse = new EmptyTypeAlias(OperationsAliasType.RESPONSE); const emptyResponse = new EmptyTypeAlias(OperationsAliasType.RESPONSE);
class Endpoint { class Endpoint {
public readonly operationId: string; public readonly path: string;
public request?: IOperationTypeAlias; public request?: IOperationTypeAlias;
public response?: IOperationTypeAlias; public response?: IOperationTypeAlias;
constructor(operationId: string) { constructor(path: string) {
this.operationId = operationId; this.path = path;
} }
toLine(): string { toLine(): string {
const reqName = this.request?.generateName() ?? emptyRequest.generateName(); const reqName = this.request?.generateName() ?? emptyRequest.generateName();
const resName = this.response?.generateName() ?? emptyResponse.generateName(); const resName = this.response?.generateName() ?? emptyResponse.generateName();
return `'${this.operationId}': { req: ${reqName}; res: ${resName} };`; return `'${this.path}': { req: ${reqName}; res: ${resName} };`;
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2024.3.1-host.3", "version": "2024.3.1-host.4",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"types": "./built/dts/index.d.ts", "types": "./built/dts/index.d.ts",
"exports": { "exports": {

View file

@ -3804,6 +3804,17 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): 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. * Get a list of other users that the specified user frequently replies to.
* *
@ -3815,6 +3826,17 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'users/get-skeb-status', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/** /**
* No description provided. * No description provided.
* *

View file

@ -506,8 +506,12 @@ import type {
UsersFollowingResponse, UsersFollowingResponse,
UsersGalleryPostsRequest, UsersGalleryPostsRequest,
UsersGalleryPostsResponse, UsersGalleryPostsResponse,
UsersGetFollowingBirthdayUsersRequest,
UsersGetFollowingBirthdayUsersResponse,
UsersGetFrequentlyRepliedUsersRequest, UsersGetFrequentlyRepliedUsersRequest,
UsersGetFrequentlyRepliedUsersResponse, UsersGetFrequentlyRepliedUsersResponse,
UsersGetSkebStatusRequest,
UsersGetSkebStatusResponse,
UsersFeaturedNotesRequest, UsersFeaturedNotesRequest,
UsersFeaturedNotesResponse, UsersFeaturedNotesResponse,
UsersListsCreateRequest, UsersListsCreateRequest,
@ -916,7 +920,9 @@ export type Endpoints = {
'users/followers': { req: UsersFollowersRequest; res: UsersFollowersResponse }; 'users/followers': { req: UsersFollowersRequest; res: UsersFollowersResponse };
'users/following': { req: UsersFollowingRequest; res: UsersFollowingResponse }; 'users/following': { req: UsersFollowingRequest; res: UsersFollowingResponse };
'users/gallery/posts': { req: UsersGalleryPostsRequest; res: UsersGalleryPostsResponse }; '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/get-frequently-replied-users': { req: UsersGetFrequentlyRepliedUsersRequest; res: UsersGetFrequentlyRepliedUsersResponse };
'users/get-skeb-status': { req: UsersGetSkebStatusRequest; res: UsersGetSkebStatusResponse };
'users/featured-notes': { req: UsersFeaturedNotesRequest; res: UsersFeaturedNotesResponse }; 'users/featured-notes': { req: UsersFeaturedNotesRequest; res: UsersFeaturedNotesResponse };
'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse }; 'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse };
'users/lists/delete': { req: UsersListsDeleteRequest; res: EmptyResponse }; 'users/lists/delete': { req: UsersListsDeleteRequest; res: EmptyResponse };

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff