1
1
mirror of https://github.com/kokonect-link/cherrypick synced 2024-11-23 14:46:44 +09:00

feat: 새 노트 알림을 묶어서 표시 (yojo-art/cherrypick#328)

This commit is contained in:
NoriDev 2024-10-29 17:14:01 +09:00
parent d25dc69eed
commit e9d7399f12
12 changed files with 91 additions and 2 deletions

View File

@ -34,6 +34,7 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE
### General
- Feat: 사용자 메뉴에서 서버를 뮤트할 수 있음 (kokonect-link/cherrypick#502)
- 이전 빌드에 추가된 기능은 관리자 전용이며, 이 빌드에서 추가된 기능은 일반 사용자용 기능입니다.
- Feat: 새 노트 알림을 묶어서 표시 (yojo-art/cherrypick#328)
### Client
- Enhance: (Friendly) 모바일 환경에서 계정 목록을 표시할 때 내 프로필을 표시함

View File

@ -171,6 +171,7 @@ receiveFollowRequest: "Follow request received"
followRequestAccepted: "Follow request accepted"
mention: "Mention"
mentions: "Mentions"
newNotes: "New notes"
directNotes: "Direct notes"
importAndExport: "Import / Export"
import: "Import"
@ -2715,6 +2716,7 @@ _notification:
reactedBySomeUsers: "{n} users reacted"
likedBySomeUsers: "{n} users liked your note"
renotedBySomeUsers: "Renote from {n} users"
notedBySomeUsers: "There are {n} new notes"
followedBySomeUsers: "Followed by {n} users"
flushNotification: "Clear notifications"
_types:

8
locales/index.d.ts vendored
View File

@ -711,6 +711,10 @@ export interface Locale extends ILocale {
*
*/
"mentions": string;
/**
* 稿
*/
"newNotes": string;
/**
* 稿
*/
@ -10668,6 +10672,10 @@ export interface Locale extends ILocale {
* {n}
*/
"renotedBySomeUsers": ParameterizedString<"n">;
/**
* {n}稿
*/
"notedBySomeUsers": ParameterizedString<"n">;
/**
* {n}
*/

View File

@ -171,6 +171,7 @@ receiveFollowRequest: "フォローリクエストされました"
followRequestAccepted: "フォローが承認されました"
mention: "メンション"
mentions: "あなた宛て"
newNotes: "新規投稿"
directNotes: "ダイレクト投稿"
importAndExport: "インポートとエクスポート"
import: "インポート"
@ -2811,6 +2812,7 @@ _notification:
reactedBySomeUsers: "{n}人がリアクションしました"
likedBySomeUsers: "{n}人がいいねしました"
renotedBySomeUsers: "{n}人がリノートしました"
notedBySomeUsers: "{n}件の新しい投稿があります"
followedBySomeUsers: "{n}人にフォローされました"
flushNotification: "通知の履歴をリセットする"
exportOfXCompleted: "{x}のエクスポートが完了しました"

View File

@ -171,6 +171,7 @@ receiveFollowRequest: "새로운 팔로우 요청이 있어요"
followRequestAccepted: "팔로우가 수락되었어요"
mention: "멘션"
mentions: "받은 멘션"
newNotes: "새 노트"
directNotes: "다이렉트 노트"
importAndExport: "가져오기 및 내보내기"
import: "가져오기"
@ -2741,6 +2742,7 @@ _notification:
reactedBySomeUsers: "{n}명이 리액션했어요"
likedBySomeUsers: "{n}명이 좋아요를 눌렀어요"
renotedBySomeUsers: "{n}명이 리노트했어요"
notedBySomeUsers: "{n}개의 새 노트가 있어요"
followedBySomeUsers: "{n}명에게 팔로우됨"
flushNotification: "모든 알림 지우기"
exportOfXCompleted: "{x} 내보내기에 성공했어요."

View File

@ -142,6 +142,27 @@ export class NotificationEntityService implements OnModuleInit {
note: noteIfNeed,
users,
});
} else if (notification.type === 'note:grouped') {
const users = (await Promise.all(notification.notifierIds.map(notifier => {
const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(notifier) : null;
if (packedUser) {
return packedUser;
}
return this.userEntityService.pack(notifier, {id: meId});
}))).filter(x => x != null);
// if all users have been deleted, don't show this notification
if (users.length === 0) {
return null;
}
return await awaitAll({
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
noteIds: notification.noteIds,
users,
});
}
// #endregion
@ -213,6 +234,7 @@ export class NotificationEntityService implements OnModuleInit {
if ('notifierId' in notification) userIds.push(notification.notifierId);
if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId));
if (notification.type === 'renote:grouped') userIds.push(...notification.userIds);
if (notification.type === 'note:grouped') userIds.push(...notification.notifierIds);
}
const users = userIds.length > 0 ? await this.usersRepository.find({
where: { id: In(userIds) },

View File

@ -140,4 +140,10 @@ export type MiGroupedNotification = MiNotification | {
createdAt: string;
noteId: MiNote['id'];
userIds: string[];
} | {
type: 'note:grouped';
id: string;
createdAt: string;
notifierIds: MiUser['id'][];
noteIds: string[];
};

View File

@ -157,6 +157,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
prevGroupedNotification.id = notification.id;
continue;
}
if (prev.type === 'note' && notification.type === 'note') {
if (prevGroupedNotification.type !== 'note:grouped') {
groupedNotifications[groupedNotifications.length - 1] = {
type: 'note:grouped',
id: '',
createdAt: notification.createdAt,
noteIds: [notification.noteId],
notifierIds: [prev.notifierId!],
};
prevGroupedNotification = groupedNotifications.at(-1)!;
}
if (!(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'note:grouped'>).notifierIds.includes(notification.notifierId)) {
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'note:grouped'>).notifierIds.push(notification.notifierId!);
}
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'note:grouped'>).noteIds.push(notification.noteId!);
prevGroupedNotification.id = notification.id;
continue;
}
groupedNotifications.push(notification);
}

View File

@ -44,6 +44,7 @@ export const groupedNotificationTypes = [
...notificationTypes,
'reaction:grouped',
'renote:grouped',
'note:grouped',
] as const;
export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as const;

View File

@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<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 === '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 === 'note:grouped'" :class="[$style.icon, $style.icon_noteGroup]"><i class="ti ti-pencil" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
<MkAvatar v-else-if="notification.type === 'exportCompleted'" :class="$style.icon" :user="$i" link preview/>
@ -67,6 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<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'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'note:grouped'">{{ i18n.tsx._notification.notedBySomeUsers({ n: notification.noteIds.length }) }}</span>
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime" :mode="defaultStore.state.enableAbsoluteTime ? 'absolute' : 'relative'"/>
</header>
@ -156,6 +158,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/>
</div>
</div>
<div v-else-if="notification.type === 'note:grouped'">
<div v-for="user of notification.users" :key="user.id" :class="$style.reactionsItem">
<MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/>
</div>
</div>
</div>
</div>
</div>
@ -268,7 +275,8 @@ const rejectGroupInvitation = () => {
.icon_reactionGroup,
.icon_reactionGroupHeart,
.icon_renoteGroup {
.icon_renoteGroup,
.icon_noteGroup {
display: grid;
align-items: center;
justify-items: center;
@ -291,6 +299,10 @@ const rejectGroupInvitation = () => {
background: var(--eventRenote);
}
.icon_noteGroup {
background: var(--eventRenote);
}
.icon_app {
border-radius: 6px;
}

View File

@ -41,11 +41,18 @@ import { globalEvents } from '@/events.js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
notUseGrouped?: boolean;
}>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? {
const pagination = computed(() => props.notUseGrouped ? {
endpoint: 'i/notifications' as const,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
} : defaultStore.reactiveState.useGroupedNotifications.value ? {
endpoint: 'i/notifications-grouped' as const,
limit: 20,
params: computed(() => ({

View File

@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="tab === 'all'" key="all">
<XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/>
</div>
<div v-else-if="tab === 'newNote'" key="newNote">
<XNotifications :class="$style.notifications" :excludeTypes="newNoteExcludeTypes" :notUseGrouped="true"/>
</div>
<div v-else-if="tab === 'mentions'" key="mention">
<MkNotes :pagination="mentionsPagination" :notification="true"/>
</div>
@ -37,6 +40,7 @@ import { globalEvents } from '@/events.js';
const tab = ref('all');
const includeTypes = ref<string[] | null>(null);
const excludeTypes = computed(() => includeTypes.value ? notificationTypes.filter(t => !includeTypes.value.includes(t)) : undefined);
const newNoteExcludeTypes = computed(() => notificationTypes.filter(t => !['note'].includes(t)));
const props = defineProps<{
disableRefreshButton?: boolean;
@ -96,6 +100,10 @@ const headerTabs = computed(() => [{
key: 'all',
title: i18n.ts.all,
icon: 'ti ti-point',
}, {
key: 'newNote',
title: i18n.ts.newNotes,
icon: 'ti ti-pencil',
}, {
key: 'mentions',
title: i18n.ts.mentions,