enhance: 誕生日のユーザーウィジェットで、今日だけに限らず、直近の誕生日ユーザーを表示できるように (#13637)
* enhance(frontend): 「今日誕生日のフォロー中ユーザー」ウィジェットをリファクタリング (cherry picked from commit24652b9364) * fix(backend): 年越しの時期で誕生日検索クエリーが誤動作する問題を修正 (MisskeyIO#577) (cherry picked from commit38581006be) * fix * spdx * delete birthday param on users/following api * 名称を一本化 * Update Changelog * Update Changelog * fix(frontend/WidgetBirthdayFollowings): ユーザーの名前が長いと投稿ボタンがはみ出てしまう問題を修正 (MisskeyIO#582) (cherry picked from commitfa47a545b1) * use module css * default 3day * Revert "delete birthday param on users/following api" This reverts commita47456c1c4. * 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 * 🎨 * i18n * fix * update changelog * 🎨 * 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>
This commit is contained in:
parent
866e675134
commit
16ffd88ecc
|
|
@ -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: ドライブのファイル一覧で自動でもっと見るを利用可能に
|
||||
|
|
|
|||
|
|
@ -2600,7 +2600,7 @@ _widgets:
|
|||
_userList:
|
||||
chooseList: "リストを選択"
|
||||
clicker: "クリッカー"
|
||||
birthdayFollowings: "今日誕生日のユーザー"
|
||||
birthdayFollowings: "もうすぐ誕生日のユーザー"
|
||||
chat: "ダイレクトメッセージ"
|
||||
|
||||
_widgetOptions:
|
||||
|
|
@ -2639,6 +2639,8 @@ _widgetOptions:
|
|||
shuffle: "表示順をシャッフル"
|
||||
duration: "ティッカーのスクロール速度(秒)"
|
||||
reverse: "逆方向にスクロール"
|
||||
_birthdayFollowings:
|
||||
period: "期間"
|
||||
|
||||
_cw:
|
||||
hide: "隠す"
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
}
|
||||
|
|
@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
me,
|
||||
{
|
||||
...options,
|
||||
userProfile: profilesMap.get(u.id),
|
||||
userProfile: profilesMap?.get(u.id),
|
||||
userRelations: userRelations,
|
||||
userMemos: userMemos,
|
||||
pinNotes: pinNotes,
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js';
|
|||
export * as 'users/flashs' from './endpoints/users/flashs.js';
|
||||
export * as 'users/followers' from './endpoints/users/followers.js';
|
||||
export * as 'users/following' from './endpoints/users/following.js';
|
||||
export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js';
|
||||
export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js';
|
||||
export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js';
|
||||
export * as 'users/lists/create' from './endpoints/users/lists/create.js';
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const paramDef = {
|
|||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
birthday: { ...birthdaySchema, nullable: true },
|
||||
birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -146,14 +146,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.andWhere('following.followerId = :userId', { userId: user.id })
|
||||
.innerJoinAndSelect('following.followee', 'followee');
|
||||
|
||||
// @deprecated use get-following-birthday-users instead.
|
||||
if (ps.birthday) {
|
||||
try {
|
||||
const birthday = ps.birthday.substring(5, 10);
|
||||
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
|
||||
birthdayUserQuery.select('user_profile.userId')
|
||||
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
|
||||
query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
|
||||
|
||||
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
|
||||
try {
|
||||
const birthday = ps.birthday.split('-');
|
||||
birthday.shift(); // 年の部分を削除
|
||||
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) });
|
||||
} catch (err) {
|
||||
throw new ApiError(meta.errors.birthdayInvalid);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
FollowingsRepository,
|
||||
UserProfilesRepository,
|
||||
} from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
requireCredential: true,
|
||||
kind: 'read:account',
|
||||
|
||||
description: 'Find users who have a birthday on the specified range.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'misskey:id',
|
||||
},
|
||||
birthday: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'UserLite',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
birthday: {
|
||||
oneOf: [{
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
begin: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
},
|
||||
end: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
},
|
||||
},
|
||||
required: ['begin', 'end'],
|
||||
}],
|
||||
},
|
||||
},
|
||||
required: ['birthday'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.andWhere('following.followerId = :userId', { userId: me.id })
|
||||
.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
|
||||
|
||||
if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) {
|
||||
const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
|
||||
|
||||
// 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換
|
||||
const begin = range.begin.month * 100 + range.begin.day;
|
||||
const end = range.end.month * 100 + range.end.day;
|
||||
|
||||
if (begin <= end) {
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end });
|
||||
} else {
|
||||
// 12/31 から 1/1 の範囲を取得するために OR で対応
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin });
|
||||
qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end });
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const { month, day } = ps.birthday as { month: number; day: number };
|
||||
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day });
|
||||
}
|
||||
|
||||
query.select('following.followeeId', 'user_id');
|
||||
query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date');
|
||||
query.orderBy('birthday_date', 'ASC');
|
||||
|
||||
const birthdayUsers = await query
|
||||
.offset(ps.offset).limit(ps.limit)
|
||||
.getRawMany<{ birthday_date: number; user_id: string }>();
|
||||
|
||||
const users = new Map<string, Packed<'UserLite'>>((
|
||||
await this.userEntityService.packMany(
|
||||
birthdayUsers.map(u => u.user_id),
|
||||
me,
|
||||
{ schema: 'UserLite' },
|
||||
)
|
||||
).map(u => [u.id, u]));
|
||||
|
||||
return birthdayUsers
|
||||
.map(item => {
|
||||
const birthday = new Date();
|
||||
birthday.setHours(0, 0, 0, 0);
|
||||
// item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定
|
||||
birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100);
|
||||
|
||||
if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) {
|
||||
birthday.setFullYear(new Date().getFullYear() + 1);
|
||||
}
|
||||
|
||||
const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`;
|
||||
return {
|
||||
id: item.user_id,
|
||||
birthday: birthdayStr,
|
||||
user: users.get(item.user_id),
|
||||
};
|
||||
})
|
||||
.filter(item => item.user != null)
|
||||
.map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkA :to="userPage(item.user)" style="overflow: clip;">
|
||||
<MkUserCardMini :user="item.user" :withChart="false" style="text-overflow: ellipsis; background: inherit; border-radius: unset;">
|
||||
<template #sub>
|
||||
<span>{{ countdownDate }}</span>
|
||||
<span> / </span>
|
||||
<span class="_monospace">@{{ acct(item.user) }}</span>
|
||||
</template>
|
||||
</MkUserCardMini>
|
||||
</MkA>
|
||||
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})">
|
||||
<i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useLowresTime } from '@/composables/use-lowres-time.js';
|
||||
import { userPage, acct } from '@/filters/user.js';
|
||||
|
||||
const props = defineProps<{
|
||||
item: Misskey.entities.UsersGetFollowingBirthdayUsersResponse[number];
|
||||
}>();
|
||||
|
||||
const now = useLowresTime();
|
||||
const nowDate = computed(() => {
|
||||
const date = new Date(now.value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
});
|
||||
const birthdayDate = computed(() => {
|
||||
const [year, month, day] = props.item.birthday.split('-').map((v) => parseInt(v, 10));
|
||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
});
|
||||
|
||||
const countdownDate = computed(() => {
|
||||
const days = Math.floor((birthdayDate.value.getTime() - nowDate.value.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (days === 0) {
|
||||
return i18n.ts.today;
|
||||
} else if (days > 0) {
|
||||
return i18n.tsx._timeIn.days({ n: days });
|
||||
} else {
|
||||
return i18n.tsx._ago.daysAgo({ n: Math.abs(days) });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 56px;
|
||||
}
|
||||
|
||||
.post {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin-right: 16px;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 100%;
|
||||
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
|
||||
}
|
||||
}
|
||||
|
||||
.postIcon {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,34 +4,43 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
|
||||
<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" class="mkw-bdayfollowings">
|
||||
<template #icon><i class="ti ti-cake"></i></template>
|
||||
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
|
||||
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ti ti-refresh"></i></button></template>
|
||||
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="fetch"><i class="ti ti-refresh"></i></button></template>
|
||||
|
||||
<div :class="$style.bdayFRoot">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else-if="users.length > 0" :class="$style.bdayFGrid">
|
||||
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee!" link preview></MkAvatar>
|
||||
<MkPagination v-slot="{ items }" :paginator="birthdayUsersPaginator">
|
||||
<div>
|
||||
<template v-for="(user, i) in items" :key="user.id">
|
||||
<div
|
||||
v-if="i > 0 && isSeparatorNeeded(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)"
|
||||
>
|
||||
<div :class="$style.date">
|
||||
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.prevText }}</span>
|
||||
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
|
||||
<span>{{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.nextText }} <i class="ti ti-chevron-down"></i></span>
|
||||
</div>
|
||||
<XUser :class="$style.user" :item="user" />
|
||||
</div>
|
||||
<XUser v-else :class="$style.user" :item="user" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-else :class="$style.bdayFFallback">
|
||||
<MkResult type="empty"/>
|
||||
</div>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { computed, markRaw, ref, watch } from 'vue';
|
||||
import { useLowresTime } from '@/composables/use-lowres-time.js';
|
||||
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
|
||||
import { useWidgetPropsManager } from './widget.js';
|
||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import XUser from './WidgetBirthdayFollowings.user.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
|
||||
const name = 'birthdayFollowings';
|
||||
|
||||
|
|
@ -41,6 +50,29 @@ const widgetPropsDef = {
|
|||
label: i18n.ts._widgetOptions.showHeader,
|
||||
default: true,
|
||||
},
|
||||
height: {
|
||||
type: 'number' as const,
|
||||
label: i18n.ts._widgetOptions.height,
|
||||
default: 300,
|
||||
},
|
||||
period: {
|
||||
type: 'radio' as const,
|
||||
label: i18n.ts._widgetOptions._birthdayFollowings.period,
|
||||
default: '3day',
|
||||
options: [{
|
||||
value: 'today' as const,
|
||||
label: i18n.ts.today,
|
||||
}, {
|
||||
value: '3day' as const,
|
||||
label: i18n.tsx.dayX({ day: 3 }),
|
||||
}, {
|
||||
value: 'week' as const,
|
||||
label: i18n.ts.oneWeek,
|
||||
}, {
|
||||
value: 'month' as const,
|
||||
label: i18n.ts.oneMonth,
|
||||
}],
|
||||
},
|
||||
} satisfies FormWithDefault;
|
||||
|
||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
||||
|
|
@ -48,62 +80,84 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
|||
const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
||||
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
||||
|
||||
const { widgetProps, configure } = useWidgetPropsManager(name,
|
||||
const { widgetProps, configure } = useWidgetPropsManager(
|
||||
name,
|
||||
widgetPropsDef,
|
||||
props,
|
||||
emit,
|
||||
);
|
||||
|
||||
const users = ref<Misskey.Endpoints['users/following']['res']>([]);
|
||||
const fetching = ref(true);
|
||||
let lastFetchedAt = '1970-01-01';
|
||||
const now = useLowresTime();
|
||||
const nextDay = new Date();
|
||||
nextDay.setHours(24, 0, 0, 0);
|
||||
let nextDayMidnightTime = nextDay.getTime();
|
||||
|
||||
const fetch = () => {
|
||||
if (!$i) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
const begin = ref<Date>(new Date());
|
||||
const end = computed(() => {
|
||||
switch (widgetProps.period) {
|
||||
case '3day':
|
||||
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 3);
|
||||
case 'week':
|
||||
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 7);
|
||||
case 'month':
|
||||
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 30);
|
||||
default:
|
||||
return begin.value;
|
||||
}
|
||||
});
|
||||
|
||||
const lfAtD = new Date(lastFetchedAt);
|
||||
lfAtD.setHours(0, 0, 0, 0);
|
||||
const birthdayUsersPaginator = markRaw(new Paginator('users/get-following-birthday-users', {
|
||||
limit: 18,
|
||||
offsetMode: true,
|
||||
computedParams: computed(() => {
|
||||
if (widgetProps.period === 'today') {
|
||||
return {
|
||||
birthday: {
|
||||
month: begin.value.getMonth() + 1,
|
||||
day: begin.value.getDate(),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
birthday: {
|
||||
begin: {
|
||||
month: begin.value.getMonth() + 1,
|
||||
day: begin.value.getDate(),
|
||||
},
|
||||
end: {
|
||||
month: end.value.getMonth() + 1,
|
||||
day: end.value.getDate(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
function fetch() {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now > lfAtD) {
|
||||
actualFetch();
|
||||
|
||||
lastFetchedAt = now.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
function actualFetch() {
|
||||
if ($i == null) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
fetching.value = true;
|
||||
misskeyApi('users/following', {
|
||||
limit: 18,
|
||||
birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`,
|
||||
userId: $i.id,
|
||||
}).then(res => {
|
||||
users.value = res;
|
||||
window.setTimeout(() => {
|
||||
// 早すぎるとチカチカする
|
||||
fetching.value = false;
|
||||
}, 100);
|
||||
});
|
||||
begin.value = now;
|
||||
}
|
||||
|
||||
useInterval(fetch, 1000 * 60, {
|
||||
immediate: true,
|
||||
afterMounted: true,
|
||||
});
|
||||
const UPDATE_INTERVAL = 1000 * 60;
|
||||
let nextDayTimer: number | null = null;
|
||||
|
||||
watch(now, (to) => {
|
||||
// 次回更新までに日付が変わる場合、日付が変わった直後に強制的に更新するタイマーをセットする
|
||||
if (nextDayMidnightTime - to <= UPDATE_INTERVAL) {
|
||||
if (nextDayTimer != null) {
|
||||
window.clearTimeout(nextDayTimer);
|
||||
nextDayTimer = null;
|
||||
}
|
||||
|
||||
nextDayTimer = window.setTimeout(() => {
|
||||
fetch();
|
||||
nextDay.setHours(24, 0, 0, 0);
|
||||
nextDayMidnightTime = nextDay.getTime();
|
||||
nextDayTimer = null;
|
||||
}, nextDayMidnightTime - to);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
defineExpose<WidgetComponentExpose>({
|
||||
name,
|
||||
|
|
@ -113,24 +167,24 @@ defineExpose<WidgetComponentExpose>({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.bdayFRoot {
|
||||
overflow: hidden;
|
||||
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--MI-margin) * 2));
|
||||
}
|
||||
.bdayFGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 42px);
|
||||
grid-template-rows: repeat(3, 42px);
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
margin: var(--MI-margin) auto;
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
background: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.bdayFFallback {
|
||||
height: 100%;
|
||||
.user {
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
font-size: 85%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
opacity: 0.75;
|
||||
padding: 8px 8px;
|
||||
margin: 0 auto;
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2121,6 +2121,8 @@ declare namespace entities {
|
|||
UsersFollowingResponse,
|
||||
UsersGalleryPostsRequest,
|
||||
UsersGalleryPostsResponse,
|
||||
UsersGetFollowingBirthdayUsersRequest,
|
||||
UsersGetFollowingBirthdayUsersResponse,
|
||||
UsersGetFrequentlyRepliedUsersRequest,
|
||||
UsersGetFrequentlyRepliedUsersResponse,
|
||||
UsersListsCreateRequest,
|
||||
|
|
@ -3727,6 +3729,12 @@ type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBo
|
|||
// @public (undocumented)
|
||||
type UsersGalleryPostsResponse = operations['users___gallery___posts']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetFollowingBirthdayUsersRequest = operations['users___get-following-birthday-users']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetFollowingBirthdayUsersResponse = operations['users___get-following-birthday-users']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json'];
|
||||
|
||||
|
|
|
|||
|
|
@ -4532,6 +4532,17 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* Find users who have a birthday on the specified range.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:account*
|
||||
*/
|
||||
request<E extends 'users/get-following-birthday-users', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* Get a list of other users that the specified user frequently replies to.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -616,6 +616,8 @@ import type {
|
|||
UsersFollowingResponse,
|
||||
UsersGalleryPostsRequest,
|
||||
UsersGalleryPostsResponse,
|
||||
UsersGetFollowingBirthdayUsersRequest,
|
||||
UsersGetFollowingBirthdayUsersResponse,
|
||||
UsersGetFrequentlyRepliedUsersRequest,
|
||||
UsersGetFrequentlyRepliedUsersResponse,
|
||||
UsersListsCreateRequest,
|
||||
|
|
@ -1067,6 +1069,7 @@ export type Endpoints = {
|
|||
'users/followers': { req: UsersFollowersRequest; res: UsersFollowersResponse };
|
||||
'users/following': { req: UsersFollowingRequest; res: UsersFollowingResponse };
|
||||
'users/gallery/posts': { req: UsersGalleryPostsRequest; res: UsersGalleryPostsResponse };
|
||||
'users/get-following-birthday-users': { req: UsersGetFollowingBirthdayUsersRequest; res: UsersGetFollowingBirthdayUsersResponse };
|
||||
'users/get-frequently-replied-users': { req: UsersGetFrequentlyRepliedUsersRequest; res: UsersGetFrequentlyRepliedUsersResponse };
|
||||
'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse };
|
||||
'users/lists/create-from-public': { req: UsersListsCreateFromPublicRequest; res: UsersListsCreateFromPublicResponse };
|
||||
|
|
|
|||
|
|
@ -619,6 +619,8 @@ export type UsersFollowingRequest = operations['users___following']['requestBody
|
|||
export type UsersFollowingResponse = operations['users___following']['responses']['200']['content']['application/json'];
|
||||
export type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBody']['content']['application/json'];
|
||||
export type UsersGalleryPostsResponse = operations['users___gallery___posts']['responses']['200']['content']['application/json'];
|
||||
export type UsersGetFollowingBirthdayUsersRequest = operations['users___get-following-birthday-users']['requestBody']['content']['application/json'];
|
||||
export type UsersGetFollowingBirthdayUsersResponse = operations['users___get-following-birthday-users']['responses']['200']['content']['application/json'];
|
||||
export type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json'];
|
||||
export type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json'];
|
||||
export type UsersListsCreateRequest = operations['users___lists___create']['requestBody']['content']['application/json'];
|
||||
|
|
|
|||
|
|
@ -3717,6 +3717,15 @@ export type paths = {
|
|||
*/
|
||||
post: operations['users___gallery___posts'];
|
||||
};
|
||||
'/users/get-following-birthday-users': {
|
||||
/**
|
||||
* users/get-following-birthday-users
|
||||
* @description Find users who have a birthday on the specified range.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:account*
|
||||
*/
|
||||
post: operations['users___get-following-birthday-users'];
|
||||
};
|
||||
'/users/get-frequently-replied-users': {
|
||||
/**
|
||||
* users/get-frequently-replied-users
|
||||
|
|
@ -34847,6 +34856,7 @@ export interface operations {
|
|||
untilDate?: number;
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
/** @description @deprecated use get-following-birthday-users instead. */
|
||||
birthday?: string | null;
|
||||
};
|
||||
};
|
||||
|
|
@ -34982,6 +34992,92 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
'users___get-following-birthday-users': {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
/** @default 0 */
|
||||
offset?: number;
|
||||
birthday: {
|
||||
month: number;
|
||||
day: number;
|
||||
} | {
|
||||
begin: {
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
end: {
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
id: string;
|
||||
birthday: string;
|
||||
user: components['schemas']['UserLite'];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
'users___get-frequently-replied-users': {
|
||||
requestBody: {
|
||||
content: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue