summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2025-12-31 22:33:26 +0900
committerGitHub <noreply@github.com>2025-12-31 22:33:26 +0900
commit16ffd88ecc16c394fd8bc532c61e6be45b053ca5 (patch)
tree0b02678fefb78eafa1fae85aaafeba078fc21870
parentfix(frontend): ウィジェットの設定画面のプレビューが利用... (diff)
downloadmisskey-16ffd88ecc16c394fd8bc532c61e6be45b053ca5.tar.gz
misskey-16ffd88ecc16c394fd8bc532c61e6be45b053ca5.tar.bz2
misskey-16ffd88ecc16c394fd8bc532c61e6be45b053ca5.zip
enhance: 誕生日のユーザーウィジェットで、今日だけに限らず、直近の誕生日ユーザーを表示できるように (#13637)
* enhance(frontend): 「今日誕生日のフォロー中ユーザー」ウィジェットをリファクタリング (cherry picked from commit 24652b9364fd7d898ac176e0da3bb6e957f72328) * fix(backend): 年越しの時期で誕生日検索クエリーが誤動作する問題を修正 (MisskeyIO#577) (cherry picked from commit 38581006be35b4725b235be9f178d2c0c9c5af33) * fix * spdx * delete birthday param on users/following api * 名称を一本化 * Update Changelog * Update Changelog * fix(frontend/WidgetBirthdayFollowings): ユーザーの名前が長いと投稿ボタンがはみ出てしまう問題を修正 (MisskeyIO#582) (cherry picked from commit fa47a545b1b770e5f3e52bb2798d1104da5dd244) * use module css * default 3day * Revert "delete birthday param on users/following api" This reverts commit a47456c1c43410409ed3607ae1d6ebb69a82324b. * Update Changelog * 日付が1ヶ月ズレている問題を修正? * fix: 日付関連のバグを修正 Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com> * build misskey-js types * add comment * Update CHANGELOG.md * migrate * change migration * UPdate Changelog * fix: revert unnecessary changes * :art: * i18n * fix * update changelog * :art: * fix lint * refactor: remove unnecessary classes * fix * fix --------- Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com> Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
-rw-r--r--CHANGELOG.md7
-rw-r--r--locales/ja-JP.yml4
-rw-r--r--packages/backend/migration/1767169026317-birthday-index.js20
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts2
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/users/following.ts15
-rw-r--r--packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts167
-rw-r--r--packages/frontend/src/components/MkUserCardMini.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue86
-rw-r--r--packages/frontend/src/widgets/WidgetBirthdayFollowings.vue198
-rw-r--r--packages/i18n/src/autogen/locale.ts8
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md8
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts11
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts3
-rw-r--r--packages/misskey-js/src/autogen/entities.ts2
-rw-r--r--packages/misskey-js/src/autogen/types.ts96
16 files changed, 546 insertions, 84 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db60e06b99..520cb109b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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: ドライブのファイル一覧で自動でもっと見るを利用可能に
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a6edca0b98..e1464151e7 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2600,7 +2600,7 @@ _widgets:
_userList:
chooseList: "リストを選択"
clicker: "クリッカー"
- birthdayFollowings: "今日誕生日のユーザー"
+ birthdayFollowings: "もうすぐ誕生日のユーザー"
chat: "ダイレクトメッセージ"
_widgetOptions:
@@ -2639,6 +2639,8 @@ _widgetOptions:
shuffle: "表示順をシャッフル"
duration: "ティッカーのスクロール速度(秒)"
reverse: "逆方向にスクロール"
+ _birthdayFollowings:
+ period: "期間"
_cw:
hide: "隠す"
diff --git a/packages/backend/migration/1767169026317-birthday-index.js b/packages/backend/migration/1767169026317-birthday-index.js
new file mode 100644
index 0000000000..972fc08c9b
--- /dev/null
+++ b/packages/backend/migration/1767169026317-birthday-index.js
@@ -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)`);
+ }
+}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index ac5b855096..0f4051e7b8 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -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,
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 9aecc0f0fd..9311c80eaa 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -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';
diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts
index 047f9a053b..326e56bc85 100644
--- a/packages/backend/src/server/api/endpoints/users/following.ts
+++ b/packages/backend/src/server/api/endpoints/users/following.ts
@@ -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);
}
diff --git a/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts b/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts
new file mode 100644
index 0000000000..124114244e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts
@@ -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'> });
+ });
+ }
+}
diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue
index dde2efd8ee..1fd43bd6e4 100644
--- a/packages/frontend/src/components/MkUserCardMini.vue
+++ b/packages/frontend/src/components/MkUserCardMini.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.body">
<span :class="$style.name"><MkUserName :user="user"/></span>
- <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
+ <span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span>
</div>
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
</div>
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue
new file mode 100644
index 0000000000..dc8ffcc818
--- /dev/null
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue
@@ -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>
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index d270f12eaf..ea577f3219 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -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 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(),
+ },
+ },
+ };
+ }
+ }),
+}));
- const lfAtD = new Date(lastFetchedAt);
- lfAtD.setHours(0, 0, 0, 0);
+function fetch() {
const now = new Date();
- now.setHours(0, 0, 0, 0);
+ begin.value = now;
+}
- if (now > lfAtD) {
- actualFetch();
+const UPDATE_INTERVAL = 1000 * 60;
+let nextDayTimer: number | null = null;
- lastFetchedAt = now.toISOString();
- }
-};
+watch(now, (to) => {
+ // 次回更新までに日付が変わる場合、日付が変わった直後に強制的に更新するタイマーをセットする
+ if (nextDayMidnightTime - to <= UPDATE_INTERVAL) {
+ if (nextDayTimer != null) {
+ window.clearTimeout(nextDayTimer);
+ nextDayTimer = null;
+ }
-function actualFetch() {
- if ($i == null) {
- users.value = [];
- fetching.value = false;
- return;
+ nextDayTimer = window.setTimeout(() => {
+ fetch();
+ nextDay.setHours(24, 0, 0, 0);
+ nextDayMidnightTime = nextDay.getTime();
+ nextDayTimer = null;
+ }, nextDayMidnightTime - to);
}
-
- 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);
- });
-}
-
-useInterval(fetch, 1000 * 60, {
- immediate: true,
- afterMounted: true,
-});
+}, { 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));
+.root {
+ container-type: inline-size;
+ background: var(--MI_THEME-panel);
}
-.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;
+
+.user {
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
}
-.bdayFFallback {
- height: 100%;
+.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>
diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts
index 5a7c0c541f..ca253a5c97 100644
--- a/packages/i18n/src/autogen/locale.ts
+++ b/packages/i18n/src/autogen/locale.ts
@@ -9885,7 +9885,7 @@ export interface Locale extends ILocale {
*/
"clicker": string;
/**
- * 今日誕生日のユーザー
+ * もうすぐ誕生日のユーザー
*/
"birthdayFollowings": string;
/**
@@ -10024,6 +10024,12 @@ export interface Locale extends ILocale {
*/
"reverse": string;
};
+ "_birthdayFollowings": {
+ /**
+ * 期間
+ */
+ "period": string;
+ };
};
"_cw": {
/**
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index fe19c00a80..6403fb2748 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2121,6 +2121,8 @@ declare namespace entities {
UsersFollowingResponse,
UsersGalleryPostsRequest,
UsersGalleryPostsResponse,
+ UsersGetFollowingBirthdayUsersRequest,
+ UsersGetFollowingBirthdayUsersResponse,
UsersGetFrequentlyRepliedUsersRequest,
UsersGetFrequentlyRepliedUsersResponse,
UsersListsCreateRequest,
@@ -3728,6 +3730,12 @@ type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBo
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'];
// @public (undocumented)
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index af3a09a16e..91f7bb5b07 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -4533,6 +4533,17 @@ declare module '../api.js' {
): 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.
*
* **Credential required**: *No*
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index c3ef3de4e6..8dc3b26bd7 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -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 };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 0d57b065dc..e9bb080636 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -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'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 2650869590..f3648e5112 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -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: {