feat(frontend): 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 (#12450)
* (add) 今日誕生日のフォロイー一覧表示 * Update Changelog * Update Changelog * 実装漏れ * create index * (fix) index
This commit is contained in:
		
							parent
							
								
									22d6fa1fdf
								
							
						
					
					
						commit
						b05d71fabf
					
				
					 8 changed files with 172 additions and 0 deletions
				
			
		| 
						 | 
				
			
			@ -21,6 +21,7 @@
 | 
			
		|||
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
 | 
			
		||||
 | 
			
		||||
### Client
 | 
			
		||||
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
 | 
			
		||||
- Enhance: 絵文字のオートコンプリート機能強化 #12364
 | 
			
		||||
- Enhance: ユーザーのRawデータを表示するページが復活
 | 
			
		||||
- Enhance: リアクション選択時に音を鳴らせるように
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -2110,6 +2110,7 @@ export interface Locale {
 | 
			
		|||
            "chooseList": string;
 | 
			
		||||
        };
 | 
			
		||||
        "clicker": string;
 | 
			
		||||
        "birthdayFollowings": string;
 | 
			
		||||
    };
 | 
			
		||||
    "_cw": {
 | 
			
		||||
        "hide": string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2014,6 +2014,7 @@ _widgets:
 | 
			
		|||
  _userList:
 | 
			
		||||
    chooseList: "リストを選択"
 | 
			
		||||
  clicker: "クリッカー"
 | 
			
		||||
  birthdayFollowings: "今日誕生日のユーザー"
 | 
			
		||||
 | 
			
		||||
_cw:
 | 
			
		||||
  hide: "隠す"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										16
									
								
								packages/backend/migration/1700902349231-add-bday-index.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								packages/backend/migration/1700902349231-add-bday-index.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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"`);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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.',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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'] },
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										127
									
								
								packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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(),
 | 
			
		||||
			userId: $i.id,
 | 
			
		||||
		}).then(res => {
 | 
			
		||||
			users.value = res;
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		lastFetchedAt = now.toISOString();
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
useInterval(fetch, 1000 * 60, {
 | 
			
		||||
	immediate: true,
 | 
			
		||||
	afterMounted: true,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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 = [
 | 
			
		||||
| 
						 | 
				
			
			@ -63,4 +64,5 @@ export const widgets = [
 | 
			
		|||
	'aichan',
 | 
			
		||||
	'userList',
 | 
			
		||||
	'clicker',
 | 
			
		||||
	'birthdayFollowings',
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue