Skip to content

Commit 16ffd88

Browse files
kakkokari-gtyihu1-liquidtaiyme
authored
enhance: 誕生日のユーザーウィジェットで、今日だけに限らず、直近の誕生日ユーザーを表示できるように (#13637)
* enhance(frontend): 「今日誕生日のフォロー中ユーザー」ウィジェットをリファクタリング (cherry picked from commit 24652b9) * fix(backend): 年越しの時期で誕生日検索クエリーが誤動作する問題を修正 (MisskeyIO#577) (cherry picked from commit 3858100) * fix * spdx * delete birthday param on users/following api * 名称を一本化 * Update Changelog * Update Changelog * fix(frontend/WidgetBirthdayFollowings): ユーザーの名前が長いと投稿ボタンがはみ出てしまう問題を修正 (MisskeyIO#582) (cherry picked from commit fa47a54) * use module css * default 3day * Revert "delete birthday param on users/following api" This reverts commit a47456c. * 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>
1 parent 866e675 commit 16ffd88

16 files changed

Lines changed: 550 additions & 88 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
## Unreleased
22

3+
### Note
4+
- `users/following``birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。
5+
36
### General
4-
-
7+
- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように
8+
(Cherry-picked from https://github.com/MisskeyIO/misskey)
9+
- 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました
510

611
### Client
712
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に

locales/ja-JP.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2600,7 +2600,7 @@ _widgets:
26002600
_userList:
26012601
chooseList: "リストを選択"
26022602
clicker: "クリッカー"
2603-
birthdayFollowings: "今日誕生日のユーザー"
2603+
birthdayFollowings: "もうすぐ誕生日のユーザー"
26042604
chat: "ダイレクトメッセージ"
26052605

26062606
_widgetOptions:
@@ -2639,6 +2639,8 @@ _widgetOptions:
26392639
shuffle: "表示順をシャッフル"
26402640
duration: "ティッカーのスクロール速度(秒)"
26412641
reverse: "逆方向にスクロール"
2642+
_birthdayFollowings:
2643+
period: "期間"
26422644

26432645
_cw:
26442646
hide: "隠す"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* SPDX-FileCopyrightText: syuilo and misskey-project
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
export class BirthdayIndex1767169026317 {
7+
name = 'BirthdayIndex1767169026317'
8+
9+
async up(queryRunner) {
10+
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
11+
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;`);
12+
await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
13+
}
14+
15+
async down(queryRunner) {
16+
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
17+
await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
18+
await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
19+
}
20+
}

packages/backend/src/core/entities/UserEntityService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit {
720720
me,
721721
{
722722
...options,
723-
userProfile: profilesMap.get(u.id),
723+
userProfile: profilesMap?.get(u.id),
724724
userRelations: userRelations,
725725
userMemos: userMemos,
726726
pinNotes: pinNotes,

packages/backend/src/server/api/endpoint-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js';
391391
export * as 'users/flashs' from './endpoints/users/flashs.js';
392392
export * as 'users/followers' from './endpoints/users/followers.js';
393393
export * as 'users/following' from './endpoints/users/following.js';
394+
export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js';
394395
export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js';
395396
export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js';
396397
export * as 'users/lists/create' from './endpoints/users/lists/create.js';

packages/backend/src/server/api/endpoints/users/following.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const paramDef = {
8686
sinceDate: { type: 'integer' },
8787
untilDate: { type: 'integer' },
8888
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
89-
birthday: { ...birthdaySchema, nullable: true },
89+
birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' },
9090
},
9191
},
9292
],
@@ -146,14 +146,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
146146
.andWhere('following.followerId = :userId', { userId: user.id })
147147
.innerJoinAndSelect('following.followee', 'followee');
148148

149+
// @deprecated use get-following-birthday-users instead.
149150
if (ps.birthday) {
150-
try {
151-
const birthday = ps.birthday.substring(5, 10);
152-
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
153-
birthdayUserQuery.select('user_profile.userId')
154-
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
151+
query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
155152

156-
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
153+
try {
154+
const birthday = ps.birthday.split('-');
155+
birthday.shift(); // 年の部分を削除
156+
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
157+
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) });
157158
} catch (err) {
158159
throw new ApiError(meta.errors.birthdayInvalid);
159160
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* SPDX-FileCopyrightText: syuilo and misskey-project
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
import { Inject, Injectable } from '@nestjs/common';
7+
import { Brackets } from 'typeorm';
8+
import { DI } from '@/di-symbols.js';
9+
import type {
10+
FollowingsRepository,
11+
UserProfilesRepository,
12+
} from '@/models/_.js';
13+
import { Endpoint } from '@/server/api/endpoint-base.js';
14+
import { UserEntityService } from '@/core/entities/UserEntityService.js';
15+
import type { Packed } from '@/misc/json-schema.js';
16+
17+
export const meta = {
18+
tags: ['users'],
19+
20+
requireCredential: true,
21+
kind: 'read:account',
22+
23+
description: 'Find users who have a birthday on the specified range.',
24+
25+
res: {
26+
type: 'array',
27+
optional: false, nullable: false,
28+
items: {
29+
type: 'object',
30+
optional: false, nullable: false,
31+
properties: {
32+
id: {
33+
type: 'string',
34+
optional: false, nullable: false,
35+
format: 'misskey:id',
36+
},
37+
birthday: {
38+
type: 'string',
39+
optional: false, nullable: false,
40+
},
41+
user: {
42+
type: 'object',
43+
optional: false, nullable: false,
44+
ref: 'UserLite',
45+
},
46+
},
47+
},
48+
},
49+
} as const;
50+
51+
export const paramDef = {
52+
type: 'object',
53+
properties: {
54+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
55+
offset: { type: 'integer', default: 0 },
56+
birthday: {
57+
oneOf: [{
58+
type: 'object',
59+
properties: {
60+
month: { type: 'integer', minimum: 1, maximum: 12 },
61+
day: { type: 'integer', minimum: 1, maximum: 31 },
62+
},
63+
required: ['month', 'day'],
64+
}, {
65+
type: 'object',
66+
properties: {
67+
begin: {
68+
type: 'object',
69+
properties: {
70+
month: { type: 'integer', minimum: 1, maximum: 12 },
71+
day: { type: 'integer', minimum: 1, maximum: 31 },
72+
},
73+
required: ['month', 'day'],
74+
},
75+
end: {
76+
type: 'object',
77+
properties: {
78+
month: { type: 'integer', minimum: 1, maximum: 12 },
79+
day: { type: 'integer', minimum: 1, maximum: 31 },
80+
},
81+
required: ['month', 'day'],
82+
},
83+
},
84+
required: ['begin', 'end'],
85+
}],
86+
},
87+
},
88+
required: ['birthday'],
89+
} as const;
90+
91+
@Injectable()
92+
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
93+
constructor(
94+
@Inject(DI.userProfilesRepository)
95+
private userProfilesRepository: UserProfilesRepository,
96+
@Inject(DI.followingsRepository)
97+
private followingsRepository: FollowingsRepository,
98+
99+
private userEntityService: UserEntityService,
100+
) {
101+
super(meta, paramDef, async (ps, me) => {
102+
const query = this.followingsRepository
103+
.createQueryBuilder('following')
104+
.andWhere('following.followerId = :userId', { userId: me.id })
105+
.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
106+
107+
if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) {
108+
const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
109+
110+
// 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換
111+
const begin = range.begin.month * 100 + range.begin.day;
112+
const end = range.end.month * 100 + range.end.day;
113+
114+
if (begin <= end) {
115+
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end });
116+
} else {
117+
// 12/31 から 1/1 の範囲を取得するために OR で対応
118+
query.andWhere(new Brackets(qb => {
119+
qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin });
120+
qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end });
121+
}));
122+
}
123+
} else {
124+
const { month, day } = ps.birthday as { month: number; day: number };
125+
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
126+
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day });
127+
}
128+
129+
query.select('following.followeeId', 'user_id');
130+
query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date');
131+
query.orderBy('birthday_date', 'ASC');
132+
133+
const birthdayUsers = await query
134+
.offset(ps.offset).limit(ps.limit)
135+
.getRawMany<{ birthday_date: number; user_id: string }>();
136+
137+
const users = new Map<string, Packed<'UserLite'>>((
138+
await this.userEntityService.packMany(
139+
birthdayUsers.map(u => u.user_id),
140+
me,
141+
{ schema: 'UserLite' },
142+
)
143+
).map(u => [u.id, u]));
144+
145+
return birthdayUsers
146+
.map(item => {
147+
const birthday = new Date();
148+
birthday.setHours(0, 0, 0, 0);
149+
// item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定
150+
birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100);
151+
152+
if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) {
153+
birthday.setFullYear(new Date().getFullYear() + 1);
154+
}
155+
156+
const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`;
157+
return {
158+
id: item.user_id,
159+
birthday: birthdayStr,
160+
user: users.get(item.user_id),
161+
};
162+
})
163+
.filter(item => item.user != null)
164+
.map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> });
165+
});
166+
}
167+
}

packages/frontend/src/components/MkUserCardMini.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
88
<MkAvatar :class="$style.avatar" :user="user" indicator/>
99
<div :class="$style.body">
1010
<span :class="$style.name"><MkUserName :user="user"/></span>
11-
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
11+
<span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span>
1212
</div>
1313
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
1414
</div>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<!--
2+
SPDX-FileCopyrightText: syuilo and misskey-project
3+
SPDX-License-Identifier: AGPL-3.0-only
4+
-->
5+
6+
<template>
7+
<div :class="$style.root">
8+
<MkA :to="userPage(item.user)" style="overflow: clip;">
9+
<MkUserCardMini :user="item.user" :withChart="false" style="text-overflow: ellipsis; background: inherit; border-radius: unset;">
10+
<template #sub>
11+
<span>{{ countdownDate }}</span>
12+
<span> / </span>
13+
<span class="_monospace">@{{ acct(item.user) }}</span>
14+
</template>
15+
</MkUserCardMini>
16+
</MkA>
17+
<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}` : ''} `})">
18+
<i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
19+
</button>
20+
</div>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { computed } from 'vue';
25+
import * as Misskey from 'misskey-js';
26+
import MkUserCardMini from '@/components/MkUserCardMini.vue';
27+
import * as os from '@/os.js';
28+
import { i18n } from '@/i18n.js';
29+
import { useLowresTime } from '@/composables/use-lowres-time.js';
30+
import { userPage, acct } from '@/filters/user.js';
31+
32+
const props = defineProps<{
33+
item: Misskey.entities.UsersGetFollowingBirthdayUsersResponse[number];
34+
}>();
35+
36+
const now = useLowresTime();
37+
const nowDate = computed(() => {
38+
const date = new Date(now.value);
39+
date.setHours(0, 0, 0, 0);
40+
return date;
41+
});
42+
const birthdayDate = computed(() => {
43+
const [year, month, day] = props.item.birthday.split('-').map((v) => parseInt(v, 10));
44+
return new Date(year, month - 1, day, 0, 0, 0, 0);
45+
});
46+
47+
const countdownDate = computed(() => {
48+
const days = Math.floor((birthdayDate.value.getTime() - nowDate.value.getTime()) / (1000 * 60 * 60 * 24));
49+
if (days === 0) {
50+
return i18n.ts.today;
51+
} else if (days > 0) {
52+
return i18n.tsx._timeIn.days({ n: days });
53+
} else {
54+
return i18n.tsx._ago.daysAgo({ n: Math.abs(days) });
55+
}
56+
});
57+
</script>
58+
59+
<style lang="scss" module>
60+
.root {
61+
box-sizing: border-box;
62+
display: grid;
63+
align-items: center;
64+
grid-template-columns: auto 56px;
65+
}
66+
67+
.post {
68+
display: flex;
69+
justify-content: center;
70+
align-items: center;
71+
height: 40px;
72+
width: 40px;
73+
margin-right: 16px;
74+
aspect-ratio: 1/1;
75+
border-radius: 100%;
76+
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
77+
78+
&:hover {
79+
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)));
80+
}
81+
}
82+
83+
.postIcon {
84+
color: var(--MI_THEME-fgOnAccent);
85+
}
86+
</style>

0 commit comments

Comments
 (0)