This commit is contained in:
mattyatea 2024-05-16 01:08:01 +09:00
parent 5e140a7ff5
commit 509181a6d0
9 changed files with 288 additions and 229 deletions

12
locales/index.d.ts vendored
View File

@ -436,6 +436,10 @@ export interface Locale extends ILocale {
* *
*/ */
"followers": string; "followers": string;
/**
*
*/
"points": string;
/** /**
* *
*/ */
@ -9291,6 +9295,10 @@ export interface Locale extends ILocale {
* *
*/ */
"achievementEarned": string; "achievementEarned": string;
/**
*
*/
"loginbonus": string;
/** /**
* *
*/ */
@ -9380,6 +9388,10 @@ export interface Locale extends ILocale {
* *
*/ */
"achievementEarned": string; "achievementEarned": string;
/**
*
*/
"loginbonus": string;
/** /**
* *
*/ */

View File

@ -105,6 +105,7 @@ note: "ノート"
notes: "ノート" notes: "ノート"
following: "フォロー" following: "フォロー"
followers: "フォロワー" followers: "フォロワー"
points: "プリズム"
followsYou: "フォローされています" followsYou: "フォローされています"
createList: "リスト作成" createList: "リスト作成"
manageLists: "リストの管理" manageLists: "リストの管理"
@ -2452,6 +2453,7 @@ _notification:
roleAssigned: "ロールが付与されました" roleAssigned: "ロールが付与されました"
emptyPushNotificationMessage: "プッシュ通知の更新をしました" emptyPushNotificationMessage: "プッシュ通知の更新をしました"
achievementEarned: "実績を獲得" achievementEarned: "実績を獲得"
loginbonus: "ログインボーナス"
testNotification: "通知テスト" testNotification: "通知テスト"
checkNotificationBehavior: "通知の表示を確かめる" checkNotificationBehavior: "通知の表示を確かめる"
sendTestNotification: "テスト通知を送信する" sendTestNotification: "テスト通知を送信する"
@ -2476,6 +2478,7 @@ _notification:
followRequestAccepted: "フォローが受理された" followRequestAccepted: "フォローが受理された"
roleAssigned: "ロールが付与された" roleAssigned: "ロールが付与された"
achievementEarned: "実績の獲得" achievementEarned: "実績の獲得"
loginbonus: "ログインボーナス"
app: "連携アプリからの通知" app: "連携アプリからの通知"
_actions: _actions:

View File

@ -163,6 +163,9 @@ export class NotificationEntityService implements OnModuleInit {
...(notification.type === 'achievementEarned' ? { ...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement, achievement: notification.achievement,
} : {}), } : {}),
...(notification.type === 'loginbonus' ? {
loginbonus: notification.loginbonus,
} : {}),
...(notification.type === 'app' ? { ...(notification.type === 'app' ? {
body: notification.customBody, body: notification.customBody,
header: notification.customHeader, header: notification.customHeader,

View File

@ -404,6 +404,7 @@ export class UserEntityService implements OnModuleInit {
userRelations?: Map<MiUser['id'], UserRelation>, userRelations?: Map<MiUser['id'], UserRelation>,
userMemos?: Map<MiUser['id'], string | null>, userMemos?: Map<MiUser['id'], string | null>,
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>, pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
todayGetPoints?: number,
}, },
): Promise<Packed<S>> { ): Promise<Packed<S>> {
const opts = Object.assign({ const opts = Object.assign({
@ -507,7 +508,7 @@ export class UserEntityService implements OnModuleInit {
iconUrl: r.iconUrl, iconUrl: r.iconUrl,
displayOrder: r.displayOrder, displayOrder: r.displayOrder,
}))) : undefined, }))) : undefined,
...(user.host == null ? { getPoints: profile!.getPoints } : {}),
...(isDetailed ? { ...(isDetailed ? {
url: profile!.url, url: profile!.url,
uri: user.uri, uri: user.uri,
@ -602,6 +603,9 @@ export class UserEntityService implements OnModuleInit {
achievements: profile!.achievements, achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length, loggedInDays: profile!.loggedInDates.length,
policies: this.roleService.getUserPolicies(user.id), policies: this.roleService.getUserPolicies(user.id),
...(opts.todayGetPoints ? {
todayGetPoints: opts.todayGetPoints,
} : {}),
} : {}), } : {}),
...(opts.includeSecrets ? { ...(opts.includeSecrets ? {

View File

@ -261,6 +261,10 @@ export class MiUserProfile {
length: 32, array: true, default: '{}', length: 32, array: true, default: '{}',
}) })
public loggedInDates: string[]; public loggedInDates: string[];
@Column('integer', {
default: '0',
})
public getPoints: number;
@Column('jsonb', { @Column('jsonb', {
default: [], default: [],

View File

@ -8,13 +8,14 @@ import type { UserProfilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ApiError } from '../error.js'; import { ApiError } from '../error.js';
export const meta = { export const meta = {
tags: ['account'], tags: ['account'],
requireCredential: true, requireCredential: true,
kind: "read:account", kind: 'read:account',
res: { res: {
type: 'object', type: 'object',
@ -43,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
private notificationService: NotificationService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
) { ) {
super(meta, paramDef, async (ps, user, token) => { super(meta, paramDef, async (ps, user, token) => {
@ -51,7 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const now = new Date(); const now = new Date();
const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
let todayGetPoints = 0;
// 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得
const userProfile = await this.userProfilesRepository.findOne({ const userProfile = await this.userProfilesRepository.findOne({
where: { where: {
@ -65,9 +66,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (!userProfile.loggedInDates.includes(today)) { if (!userProfile.loggedInDates.includes(today)) {
todayGetPoints = Math.floor(Math.random() * 5) + 1;
this.userProfilesRepository.update({ userId: user.id }, { this.userProfilesRepository.update({ userId: user.id }, {
loggedInDates: [...userProfile.loggedInDates, today], loggedInDates: [...userProfile.loggedInDates, today],
}); });
this.userProfilesRepository.update({ userId: user.id }, {
getPoints: userProfile.getPoints + todayGetPoints,
});
this.notificationService.createNotification(user.id, 'loginbonus', {
loginbonus: todayGetPoints,
});
userProfile.loggedInDates = [...userProfile.loggedInDates, today]; userProfile.loggedInDates = [...userProfile.loggedInDates, today];
} }
@ -75,6 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
schema: 'MeDetailed', schema: 'MeDetailed',
includeSecrets: isSecure, includeSecrets: isSecure,
userProfile, userProfile,
...(todayGetPoints && { todayGetPoints }),
}); });
}); });
} }

View File

@ -42,6 +42,7 @@ export const notificationTypes = [
'achievementEarned', 'achievementEarned',
'app', 'app',
'test', 'test',
'loginbonus',
] as const; ] as const;
export const groupedNotificationTypes = [ export const groupedNotificationTypes = [

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.head"> <div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'loginbonus'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_quote]: notification.type === 'quote', [$style.t_quote]: notification.type === 'quote',
[$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_pollEnded]: notification.type === 'pollEnded',
[$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_achievementEarned]: notification.type === 'loginbonus',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
}]" }]"
> >
@ -37,6 +38,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'loginbonus'" class="ti ti-medal"></i>
<template v-else-if="notification.type === 'roleAssigned'"> <template v-else-if="notification.type === 'roleAssigned'">
<img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/>
<i v-else class="ti ti-badges"></i> <i v-else class="ti ti-badges"></i>
@ -56,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'loginbonus'">{{ i18n.ts._notification.loginbonus }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span> <span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
@ -94,10 +98,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA> </MkA>
<div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
{{ notification.role.name }} {{ notification.role.name }}
</div> <div v-else-if="notification.type === 'loginbonus'" :class="$style.text">
{{ notification.loginbonus }}プリズム入手しました
</div> </div>
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }} {{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA> </MkA>
<template v-else-if="notification.type === 'follow'"> <template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
</template> </template>

View File

@ -11,159 +11,175 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> -->
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps"> <div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<MkRemoteInfoUpdate v-if="user.host != null" :UserId="user.id" class="warn"/> <MkRemoteInfoUpdate v-if="user.host != null" :UserId="user.id" class="warn"/>
<div :key="user.id" class="main _panel"> <div :key="user.id" class="main _panel">
<div class="banner-container" :style="style"> <div class="banner-container" :style="style">
<div ref="bannerEl" class="banner" :style="style"></div> <div ref="bannerEl" class="banner" :style="style"></div>
<div class="fade"></div> <div class="fade"></div>
<div class="title"> <div class="title">
<MkUserName class="name" :user="user" :nowrap="true"/> <MkUserName class="name" :user="user" :nowrap="true"/>
<div class="bottom"> <div class="bottom">
<span class="username"><MkAcct :user="user" :detail="true"/></span> <span class="username"><MkAcct :user="user" :detail="true"/></span>
<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i
class="ti ti-shield"></i></span> class="ti ti-shield"
<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> ></i></span>
<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
<button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
<i class="ti ti-edit"/> {{ i18n.ts.addMemo }} <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea">
</button> <i class="ti ti-edit"/> {{ i18n.ts.addMemo }}
</div> </button>
</div> </div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> </div>
<div v-if="$i" class="actions"> <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> <div v-if="$i" class="actions">
<MkNotifyButton v-if="$i.id != user.id " :user="user"></MkNotifyButton> <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" <MkNotifyButton v-if="$i.id != user.id " :user="user"></MkNotifyButton>
class="koudoku"/> <MkFollowButton
</div> v-if="$i.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true"
</div> class="koudoku"
<MkAvatar class="avatar" :user="user" indicator/> />
<div class="title"> </div>
<MkUserName :user="user" :nowrap="false" class="name"/> </div>
<div class="bottom"> <MkAvatar class="avatar" :user="user" indicator/>
<span class="username"><MkAcct :user="user" :detail="true"/></span> <div class="title">
<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i <MkUserName :user="user" :nowrap="false" class="name"/>
class="ti ti-shield"></i></span> <div class="bottom">
<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> <span class="username"><MkAcct :user="user" :detail="true"/></span>
<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i
</div> class="ti ti-shield"
</div> ></i></span>
<div v-if="user.roles.length > 0" class="roles"> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
:style="{ '--color': role.color }"> </div>
</div>
<div v-if="user.roles.length > 0" class="roles">
<span
v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role"
:style="{ '--color': role.color }"
>
<MkA v-adaptive-bg :to="`/roles/${role.id}`"> <MkA v-adaptive-bg :to="`/roles/${role.id}`">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/> <img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
{{ role.name }} {{ role.name }}
</MkA> </MkA>
</span> </span>
</div> </div>
<div v-if="iAmModerator" class="moderationNote"> <div v-if="iAmModerator" class="moderationNote">
<MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" <MkTextarea
v-model="moderationNote" manualSave> v-if="editModerationNote || (moderationNote != null && moderationNote !== '')"
<template #label>{{ i18n.ts.moderationNote }}</template> v-model="moderationNote" manualSave
</MkTextarea> >
<div v-else> <template #label>{{ i18n.ts.moderationNote }}</template>
<MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton> </MkTextarea>
</div> <div v-else>
</div> <MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton>
<div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}"> </div>
<div class="heading" v-text="i18n.ts.memo"/> </div>
<textarea <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}">
ref="memoTextareaEl" <div class="heading" v-text="i18n.ts.memo"/>
v-model="memoDraft" <textarea
rows="1" ref="memoTextareaEl"
@focus="isEditingMemo = true" v-model="memoDraft"
@blur="updateMemo" rows="1"
@input="adjustMemoTextarea" @focus="isEditingMemo = true"
/> @blur="updateMemo"
</div> @input="adjustMemoTextarea"
<div class="description"> />
<MkOmit> </div>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user"/> <div class="description">
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> <MkOmit>
</MkOmit> <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user"/>
</div> <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
<div class="fields system"> </MkOmit>
<dl v-if="user.location" class="field"> </div>
<dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> <div class="fields system">
<dd class="value">{{ user.location }}</dd> <dl v-if="user.location" class="field">
</dl> <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt>
<dl v-if="user.birthday" class="field"> <dd class="value">{{ user.location }}</dd>
<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> </dl>
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ <dl v-if="user.birthday" class="field">
i18n.tsx.yearsOld({age}) <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
}}) <dd class="value">
</dd> {{ user.birthday.replace('-', '/').replace('-', '/') }} ({{
</dl> i18n.tsx.yearsOld({age})
<dl class="field"> }})
<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> </dd>
<dd class="value">{{ dateString(user.createdAt) }} ( </dl>
<MkTime :time="user.createdAt"/> <dl class="field">
) <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>
</dd> <dd class="value">
</dl> {{ dateString(user.createdAt) }} (
</div> <MkTime :time="user.createdAt"/>
<div v-if="user.fields.length > 0" class="fields"> )
<dl v-for="(field, i) in user.fields" :key="i" class="field"> </dd>
<dt class="name"> </dl>
<Mfm :text="field.name" :plain="true" :colored="false"/> </div>
</dt> <div v-if="user.fields.length > 0" class="fields">
<dd class="value"> <dl v-for="(field, i) in user.fields" :key="i" class="field">
<Mfm :text="field.value" :author="user" :colored="false"/> <dt class="name">
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" <Mfm :text="field.name" :plain="true" :colored="false"/>
class="ti ti-circle-check" :class="$style.verifiedLink"></i> </dt>
</dd> <dd class="value">
</dl> <Mfm :text="field.value" :author="user" :colored="false"/>
</div> <i
<div class="status"> v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink"
<MkA :to="userPage(user)"> class="ti ti-circle-check" :class="$style.verifiedLink"
<b>{{ number(user.notesCount) }}</b> ></i>
<span>{{ i18n.ts.notes }}</span> </dd>
</MkA> </dl>
<MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')"> </div>
<b>{{ number(user.followingCount) }}</b> <div class="status">
<span>{{ i18n.ts.following }}</span> <MkA :to="userPage(user)">
</MkA> <b>{{ number(user.notesCount) }}</b>
<MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')"> <span>{{ i18n.ts.notes }}</span>
<b>{{ number(user.followersCount) }}</b> </MkA>
<span>{{ i18n.ts.followers }}</span> <MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')">
</MkA> <b>{{ number(user.followingCount) }}</b>
</div> <span>{{ i18n.ts.following }}</span>
</div> </MkA>
</div> <MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')">
<b>{{ number(user.followersCount) }}</b>
<span>{{ i18n.ts.followers }}</span>
</MkA>
<MkA>
<b> {{ number(user.getPoints) }}</b>
<span>{{ i18n.ts.points }}</span>
</MkA>
</div>
</div>
</div>
<div class="contents _gaps"> <div class="contents _gaps">
<div v-if="user.pinnedNotes.length > 0" class="_gaps"> <div v-if="user.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/>
</div> </div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow"> <template v-if="narrow">
<MkLazy> <MkLazy>
<XFiles :key="user.id" :user="user"/> <XFiles :key="user.id" :user="user"/>
</MkLazy> </MkLazy>
<MkLazy> <MkLazy>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</MkLazy> </MkLazy>
</template> </template>
<div> <div>
<div style="margin-bottom: 8px;">{{ i18n.ts._sfx.note }}</div> <div style="margin-bottom: 8px;">{{ i18n.ts._sfx.note }}</div>
<MkNotes :class="$style.tl" :noGap="true" :pagination="Notes"/> <MkNotes :class="$style.tl" :noGap="true" :pagination="Notes"/>
</div> </div>
</div> </div>
</div> </div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<XFiles :key="user.id" :user="user"/> <XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity :key="user.id" :user="user"/>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch, ref } from 'vue'; import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkFollowButton from '@/components/MkFollowButton.vue'; import MkFollowButton from '@/components/MkFollowButton.vue';
@ -173,36 +189,36 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkOmit from '@/components/MkOmit.vue'; import MkOmit from '@/components/MkOmit.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import {getScrollPosition} from '@/scripts/scroll.js'; import { getScrollPosition } from '@/scripts/scroll.js';
import {getUserMenu} from '@/scripts/get-user-menu.js'; import { getUserMenu } from '@/scripts/get-user-menu.js';
import number from '@/filters/number.js'; import number from '@/filters/number.js';
import {userPage} from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import {i18n} from '@/i18n.js'; import { i18n } from '@/i18n.js';
import {$i, iAmModerator} from '@/account.js'; import { $i, iAmModerator } from '@/account.js';
import {dateString} from '@/filters/date.js'; import { dateString } from '@/filters/date.js';
import {confetti} from '@/scripts/confetti.js'; import { confetti } from '@/scripts/confetti.js';
import {misskeyApi} from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import {isFollowingVisibleForMe, isFollowersVisibleForMe} from '@/scripts/isFfVisibleForMe.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import MkNotifyButton from "@/components/MkNotifyButton.vue"; import MkNotifyButton from '@/components/MkNotifyButton.vue';
import MkRemoteInfoUpdate from "@/components/MkRemoteInfoUpdate.vue"; import MkRemoteInfoUpdate from '@/components/MkRemoteInfoUpdate.vue';
import MkNotes from "@/components/MkNotes.vue"; import MkNotes from '@/components/MkNotes.vue';
import MkLazy from "@/components/global/MkLazy.vue"; import MkLazy from '@/components/global/MkLazy.vue';
function calcAge(birthdate: string): number { function calcAge(birthdate: string): number {
const date = new Date(birthdate); const date = new Date(birthdate);
const now = new Date(); const now = new Date();
let yearDiff = now.getFullYear() - date.getFullYear(); let yearDiff = now.getFullYear() - date.getFullYear();
const monthDiff = now.getMonth() - date.getMonth(); const monthDiff = now.getMonth() - date.getMonth();
const pastDate = now.getDate() < date.getDate(); const pastDate = now.getDate() < date.getDate();
if (monthDiff < 0 || (monthDiff === 0 && pastDate)) { if (monthDiff < 0 || (monthDiff === 0 && pastDate)) {
yearDiff--; yearDiff--;
} }
return yearDiff; return yearDiff;
} }
const XFiles = defineAsyncComponent(() => import('./index.files.vue')); const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
@ -214,7 +230,7 @@ const props = withDefaults(defineProps<{
/** Test only; MkNotes currently causes problems in vitest */ /** Test only; MkNotes currently causes problems in vitest */
disableNotes: boolean; disableNotes: boolean;
}>(), { }>(), {
disableNotes: false, disableNotes: false,
}); });
const router = useRouter(); const router = useRouter();
@ -231,107 +247,107 @@ const moderationNote = ref(props.user.moderationNote);
const editModerationNote = ref(false); const editModerationNote = ref(false);
watch(moderationNote, async () => { watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', {userId: props.user.id, text: moderationNote.value }); await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value });
}); });
const pagination = { const pagination = {
endpoint: 'users/featured-notes' as const, endpoint: 'users/featured-notes' as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
userId: props.user.id, userId: props.user.id,
})), })),
};
const Notes = {
endpoint: 'users/notes' as const,
limit: 10,
params: computed(() => ({
userId: props.user.id,
})),
}; };
const Notes ={
endpoint: 'users/notes' as const,
limit: 10,
params: computed(() => ({
userId: props.user.id,
})),
}
const style = computed(() => { const style = computed(() => {
if (props.user.bannerUrl == null) return {}; if (props.user.bannerUrl == null) return {};
return { return {
backgroundImage: `url(${props.user.bannerUrl})`, backgroundImage: `url(${props.user.bannerUrl})`,
}; };
}); });
const age = computed(() => { const age = computed(() => {
return calcAge(props.user.birthday); return calcAge(props.user.birthday);
}); });
function menu(ev: MouseEvent) { function menu(ev: MouseEvent) {
const {menu, cleanup} = getUserMenu(user.value, router); const { menu, cleanup } = getUserMenu(user.value, router);
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
} }
function parallaxLoop() { function parallaxLoop() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop); parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
parallax(); parallax();
} }
function parallax() { function parallax() {
const banner = bannerEl.value as any; const banner = bannerEl.value as any;
if (banner == null) return; if (banner == null) return;
const top = getScrollPosition(rootEl.value); const top = getScrollPosition(rootEl.value);
if (top < 0) return; if (top < 0) return;
const z = 1.75; // () const z = 1.75; // ()
const pos = -(top / z); const pos = -(top / z);
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
} }
function showMemoTextarea() { function showMemoTextarea() {
isEditingMemo.value = true; isEditingMemo.value = true;
nextTick(() => { nextTick(() => {
memoTextareaEl.value?.focus(); memoTextareaEl.value?.focus();
}); });
} }
function adjustMemoTextarea() { function adjustMemoTextarea() {
if (!memoTextareaEl.value) return; if (!memoTextareaEl.value) return;
memoTextareaEl.value.style.height = '0px'; memoTextareaEl.value.style.height = '0px';
memoTextareaEl.value.style.height = `${memoTextareaEl.value.scrollHeight}px`; memoTextareaEl.value.style.height = `${memoTextareaEl.value.scrollHeight}px`;
} }
async function updateMemo() { async function updateMemo() {
await misskeyApi('users/update-memo', { await misskeyApi('users/update-memo', {
memo: memoDraft.value, memo: memoDraft.value,
userId: props.user.id, userId: props.user.id,
}); });
isEditingMemo.value = false; isEditingMemo.value = false;
} }
watch([props.user], () => { watch([props.user], () => {
memoDraft.value = props.user.memo; memoDraft.value = props.user.memo;
}); });
onMounted(() => { onMounted(() => {
window.requestAnimationFrame(parallaxLoop); window.requestAnimationFrame(parallaxLoop);
narrow.value = rootEl.value!.clientWidth < 1000; narrow.value = rootEl.value!.clientWidth < 1000;
if (props.user.birthday) { if (props.user.birthday) {
const m = new Date().getMonth() + 1; const m = new Date().getMonth() + 1;
const d = new Date().getDate(); const d = new Date().getDate();
const bm = parseInt(props.user.birthday.split('-')[1]); const bm = parseInt(props.user.birthday.split('-')[1]);
const bd = parseInt(props.user.birthday.split('-')[2]); const bd = parseInt(props.user.birthday.split('-')[2]);
if (m === bm && d === bd) { if (m === bm && d === bd) {
confetti({ confetti({
duration: 1000 * 4, duration: 1000 * 4,
}); });
} }
} }
nextTick(() => { nextTick(() => {
adjustMemoTextarea(); adjustMemoTextarea();
}); });
}); });
onUnmounted(() => { onUnmounted(() => {
if (parallaxAnimationId.value) { if (parallaxAnimationId.value) {
window.cancelAnimationFrame(parallaxAnimationId.value); window.cancelAnimationFrame(parallaxAnimationId.value);
} }
}); });
</script> </script>