diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-12-31 22:33:26 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-31 22:33:26 +0900 |
| commit | 16ffd88ecc16c394fd8bc532c61e6be45b053ca5 (patch) | |
| tree | 0b02678fefb78eafa1fae85aaafeba078fc21870 | |
| parent | fix(frontend): ウィジェットの設定画面のプレビューが利用... (diff) | |
| download | misskey-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.md | 7 | ||||
| -rw-r--r-- | locales/ja-JP.yml | 4 | ||||
| -rw-r--r-- | packages/backend/migration/1767169026317-birthday-index.js | 20 | ||||
| -rw-r--r-- | packages/backend/src/core/entities/UserEntityService.ts | 2 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoint-list.ts | 1 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/users/following.ts | 15 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts | 167 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUserCardMini.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue | 86 | ||||
| -rw-r--r-- | packages/frontend/src/widgets/WidgetBirthdayFollowings.vue | 198 | ||||
| -rw-r--r-- | packages/i18n/src/autogen/locale.ts | 8 | ||||
| -rw-r--r-- | packages/misskey-js/etc/misskey-js.api.md | 8 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/endpoint.ts | 3 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/entities.ts | 2 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/types.ts | 96 |
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: { |