Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontend): 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 #12450

Merged
merged 10 commits into from
Nov 30, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正

### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
- Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように
Expand Down
1 change: 1 addition & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2100,6 +2100,7 @@ export interface Locale {
"chooseList": string;
};
"clicker": string;
"birthdayFollowings": string;
};
"_cw": {
"hide": string;
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2004,6 +2004,7 @@ _widgets:
_userList:
chooseList: "リストを選択"
clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー"

_cw:
hide: "隠す"
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/migration/1700902349231-add-bday-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class AddBdayIndex1700902349231 {
name = 'AddBdayIndex1700902349231'

async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (SUBSTR("birthday", 6, 5))`);
}

async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/models/UserProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class MiUserProfile {
})
public location: string | null;

@Index()
@Column('char', {
length: 10, nullable: true,
comment: 'The birthday (YYYY-MM-DD) of the User.',
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/src/server/api/endpoints/users/following.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export const meta = {
code: 'FORBIDDEN',
id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba',
},

birthdayInvalid: {
message: 'Birthday date format is invalid.',
code: 'BIRTHDAY_DATE_FORMAT_INVALID',
id: 'a2b007b9-4782-4eba-abd3-93b05ed4130d',
},
},
} as const;

Expand All @@ -59,6 +65,8 @@ export const paramDef = {
nullable: true,
description: 'The local host is represented with `null`.',
},

birthday: { type: 'string', nullable: true },
},
anyOf: [
{ required: ['userId'] },
Expand Down Expand Up @@ -117,6 +125,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee');

if (ps.birthday) {
try {
const d = new Date(ps.birthday);
d.setHours(0, 0, 0, 0);
const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);

query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
} catch (err) {
throw new ApiError(meta.errors.birthdayInvalid);
}
}

const followings = await query
.limit(ps.limit)
.getMany();
Expand Down
127 changes: 127 additions & 0 deletions packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->

<template>
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
<template #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</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>
</div>
<div v-else :class="$style.bdayFFallback">
<img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
</MkContainer>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import { GetFormResultType } from '@/scripts/form.js';
import MkContainer from '@/components/MkContainer.vue';
import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/account.js';

const name = i18n.ts._widgets.birthdayFollowings;

const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: true,
},
};

type WidgetProps = GetFormResultType<typeof widgetPropsDef>;

const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();

const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);

const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
const fetching = ref(true);
let lastFetchedAt = '1970-01-01';

const fetch = () => {
if (!$i) {
users.value = [];
fetching.value = false;
return;
}

const lfAtD = new Date(lastFetchedAt);
lfAtD.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);

if (now > lfAtD) {
os.api('users/following', {
limit: 18,
birthday: now.toISOString(),
Comment on lines +70 to +75
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これタイムゾーン吹き飛ぶせいで UTC 以外(e.g. JST)だと一日ズレる

userId: $i.id,
}).then(res => {
users.value = res;
fetching.value = false;
});

lastFetchedAt = now.toISOString();
}
};

useInterval(fetch, 1000 * 60, {
immediate: true,
afterMounted: true,
});
Comment on lines +86 to +89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

誕生日は1日に1回しか変わらないので60秒おきにfetchする理由はなさそう
タイムゾーンを考慮しても少なくとも1時間おきでよいのでは(半日とか1日おきでもよさそう。それか日付の変わり目に setTimeoutかな)
オンライン数分リクエストが飛ぶことを考えるとioでは7000リクエストぐらい一気にきそう

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これ日付変わるのを検知しないと実際にはfetchしないようになっています


defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

<style lang="scss" module>
.bdayFRoot {
overflow: hidden;
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--margin) * 2));
}
.bdayFGrid {
display: grid;
grid-template-columns: repeat(6, 42px);
grid-template-rows: repeat(3, 42px);
place-content: center;
gap: 8px;
margin: var(--margin) auto;
}

.bdayFFallback {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

.bdayFFallbackImage {
height: 96px;
width: auto;
max-width: 90%;
margin-bottom: 8px;
border-radius: var(--radius);
}
</style>
2 changes: 2 additions & 0 deletions packages/frontend/src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function(app: App) {
app.component('WidgetAichan', defineAsyncComponent(() => import('./WidgetAichan.vue')));
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
}

export const widgets = [
Expand Down Expand Up @@ -63,4 +64,5 @@ export const widgets = [
'aichan',
'userList',
'clicker',
'birthdayFollowings',
];
Loading