feat: fold notifications
This commit is contained in:
parent
ac9a07d25a
commit
7e9633a36b
9 changed files with 443 additions and 8 deletions
|
@ -2146,6 +2146,7 @@ _notification:
|
||||||
reacted: "reacted to your post"
|
reacted: "reacted to your post"
|
||||||
renoted: "boosted your post"
|
renoted: "boosted your post"
|
||||||
voted: "voted on your poll"
|
voted: "voted on your poll"
|
||||||
|
andCountUsers: "and {count} users {acted}"
|
||||||
_types:
|
_types:
|
||||||
all: "All"
|
all: "All"
|
||||||
follow: "New followers"
|
follow: "New followers"
|
||||||
|
@ -2232,3 +2233,5 @@ autocorrectNoteLanguage: "Show a warning if the post language does not match the
|
||||||
incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected
|
incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected
|
||||||
{current}.\nWould you like to set the language to {detected} instead?"
|
{current}.\nWould you like to set the language to {detected} instead?"
|
||||||
noteEditHistory: "Post edit history"
|
noteEditHistory: "Post edit history"
|
||||||
|
experimental: "Experimental"
|
||||||
|
foldNotification: "Collapse notifications of the same type"
|
||||||
|
|
|
@ -1787,6 +1787,7 @@ _notification:
|
||||||
reacted: 回应了您的帖子
|
reacted: 回应了您的帖子
|
||||||
voted: 在您的问卷调查中投了票
|
voted: 在您的问卷调查中投了票
|
||||||
renoted: 转发了您的帖子
|
renoted: 转发了您的帖子
|
||||||
|
andCountUsers: "等 {count} 名用户{acted}"
|
||||||
_deck:
|
_deck:
|
||||||
alwaysShowMainColumn: "总是显示主列"
|
alwaysShowMainColumn: "总是显示主列"
|
||||||
columnAlign: "列对齐"
|
columnAlign: "列对齐"
|
||||||
|
@ -2059,3 +2060,5 @@ autocorrectNoteLanguage: 当帖子语言不符合自动检测的结果的时候
|
||||||
incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?"
|
incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?"
|
||||||
noteEditHistory: "帖子编辑历史"
|
noteEditHistory: "帖子编辑历史"
|
||||||
media: 媒体
|
media: 媒体
|
||||||
|
experimental: "实验性"
|
||||||
|
foldNotification: "折叠同类型通知"
|
||||||
|
|
246
packages/client/src/components/MkNotificationFolded.vue
Normal file
246
packages/client/src/components/MkNotificationFolded.vue
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="elRef"
|
||||||
|
v-size="{ max: [500, 450] }"
|
||||||
|
class="qglefbjs notification"
|
||||||
|
:class="notification.type"
|
||||||
|
>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="info">
|
||||||
|
<span class="sub-icon" :class="notification.type">
|
||||||
|
<i
|
||||||
|
v-if="notification.type === 'renote'"
|
||||||
|
:class="icon('ph-rocket-launch', false)"
|
||||||
|
></i>
|
||||||
|
<XReactionIcon
|
||||||
|
v-else-if="
|
||||||
|
showEmojiReactions && notification.type === 'reaction'
|
||||||
|
"
|
||||||
|
ref="reactionRef"
|
||||||
|
:reaction="
|
||||||
|
notification.reaction
|
||||||
|
? notification.reaction.replace(
|
||||||
|
/^:(\w+):$/,
|
||||||
|
':$1@.:',
|
||||||
|
)
|
||||||
|
: notification.reaction
|
||||||
|
"
|
||||||
|
:custom-emojis="notification.note.emojis"
|
||||||
|
:no-style="true"
|
||||||
|
/>
|
||||||
|
<XReactionIcon
|
||||||
|
v-else-if="
|
||||||
|
!showEmojiReactions && notification.type === 'reaction'
|
||||||
|
"
|
||||||
|
:reaction="defaultReaction"
|
||||||
|
:no-style="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="avatars">
|
||||||
|
<MkAvatar
|
||||||
|
v-for="user in users"
|
||||||
|
class="avatar"
|
||||||
|
:user="user"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
{{ getText() }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
<MkTime
|
||||||
|
v-if="withTime"
|
||||||
|
:time="notification.createdAt"
|
||||||
|
class="time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- <MkA
|
||||||
|
v-if="notification.type === 'reaction'"
|
||||||
|
class="text"
|
||||||
|
:to="notePage(notification.note)"
|
||||||
|
:title="getNoteSummary(notification.note)"
|
||||||
|
>
|
||||||
|
<Mfm
|
||||||
|
:text="getNoteSummary(notification.note)"
|
||||||
|
:plain="true"
|
||||||
|
:nowrap="!full"
|
||||||
|
:lang="notification.note.lang"
|
||||||
|
:custom-emojis="notification.note.emojis"
|
||||||
|
/>
|
||||||
|
</MkA> -->
|
||||||
|
<XNote
|
||||||
|
v-if="notification.type === 'renote'"
|
||||||
|
class="content"
|
||||||
|
:note="notification.note.renote"
|
||||||
|
:hide-footer="true"
|
||||||
|
/>
|
||||||
|
<XNote
|
||||||
|
v-else
|
||||||
|
class="content"
|
||||||
|
:note="notification.note"
|
||||||
|
:hide-footer="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import type { Connection } from "firefish-js/src/streaming";
|
||||||
|
import type { Channels } from "firefish-js/src/streaming.types";
|
||||||
|
import XReactionIcon from "@/components/MkReactionIcon.vue";
|
||||||
|
import XReactionTooltip from "@/components/MkReactionTooltip.vue";
|
||||||
|
import { i18n } from "@/i18n";
|
||||||
|
import * as os from "@/os";
|
||||||
|
import { useStream } from "@/stream";
|
||||||
|
import { useTooltip } from "@/scripts/use-tooltip";
|
||||||
|
import { defaultStore } from "@/store";
|
||||||
|
import { instance } from "@/instance";
|
||||||
|
import icon from "@/scripts/icon";
|
||||||
|
import type {
|
||||||
|
NotificationFolded,
|
||||||
|
ReactionNotificationFolded,
|
||||||
|
} from "@/types/notification";
|
||||||
|
import XNote from "@/components/MkNote.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
notification: NotificationFolded;
|
||||||
|
withTime?: boolean;
|
||||||
|
full?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
withTime: false,
|
||||||
|
full: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stream = useStream();
|
||||||
|
|
||||||
|
const elRef = ref<HTMLElement | null>(null);
|
||||||
|
const reactionRef = ref<InstanceType<typeof XReactionIcon> | null>(null);
|
||||||
|
|
||||||
|
const showEmojiReactions =
|
||||||
|
defaultStore.state.enableEmojiReactions ||
|
||||||
|
defaultStore.state.showEmojisInReactionNotifications;
|
||||||
|
const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReaction)
|
||||||
|
? instance.defaultReaction
|
||||||
|
: "⭐";
|
||||||
|
|
||||||
|
const users = ref(props.notification.users.slice(0, 5));
|
||||||
|
const userleft = ref(props.notification.users.length - users.value.length);
|
||||||
|
|
||||||
|
let readObserver: IntersectionObserver | undefined;
|
||||||
|
let connection: Connection<Channels["main"]> | null = null;
|
||||||
|
|
||||||
|
function getText() {
|
||||||
|
let res = "";
|
||||||
|
switch (props.notification.type) {
|
||||||
|
case "renote":
|
||||||
|
res = i18n.ts._notification.renoted;
|
||||||
|
break;
|
||||||
|
case "reaction":
|
||||||
|
res = i18n.ts._notification.reacted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (userleft.value > 0) {
|
||||||
|
res = i18n.t("_notification.andCountUsers", {
|
||||||
|
count: userleft.value,
|
||||||
|
acted: res,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
useTooltip(reactionRef, (showing) => {
|
||||||
|
const n = props.notification as ReactionNotificationFolded;
|
||||||
|
os.popup(
|
||||||
|
XReactionTooltip,
|
||||||
|
{
|
||||||
|
showing,
|
||||||
|
reaction: n.reaction
|
||||||
|
? n.reaction.replace(/^:(\w+):$/, ":$1@.:")
|
||||||
|
: n.reaction,
|
||||||
|
emojis: n.note.emojis,
|
||||||
|
targetElement: reactionRef.value!.$el,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
"closed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const unreadNotifications = props.notification.notifications.filter(
|
||||||
|
(n) => !n.isRead,
|
||||||
|
);
|
||||||
|
|
||||||
|
readObserver = new IntersectionObserver((entries, observer) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
for (const u of unreadNotifications) {
|
||||||
|
stream.send("readNotification", {
|
||||||
|
id: u.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
readObserver.observe(elRef.value!);
|
||||||
|
|
||||||
|
connection = stream.useChannel("main");
|
||||||
|
connection.on("readAllNotifications", () => readObserver!.disconnect());
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (readObserver) readObserver.disconnect();
|
||||||
|
if (connection) connection.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.qglefbjs {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 0.9em;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
contain: content;
|
||||||
|
|
||||||
|
&.max-width_500px > .meta{
|
||||||
|
padding-block: 16px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
&.max-width_450px > .meta {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .meta {
|
||||||
|
margin-top: 1px; // Otherwise it will cover the line
|
||||||
|
padding: 24px 32px 0 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
white-space: nowrap;
|
||||||
|
> .info {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
// flex-grow: 1;
|
||||||
|
// display: inline-flex;
|
||||||
|
> .sub-icon {
|
||||||
|
margin-right: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
> .avatars > .avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
> .time {
|
||||||
|
margin-left: auto;
|
||||||
|
// flex-grow: 0;
|
||||||
|
// flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -13,13 +13,21 @@
|
||||||
|
|
||||||
<template #default="{ items: notifications }">
|
<template #default="{ items: notifications }">
|
||||||
<XList
|
<XList
|
||||||
|
:items="convertNotification(notifications)"
|
||||||
v-slot="{ item: notification }"
|
v-slot="{ item: notification }"
|
||||||
class="elsfgstc"
|
class="elsfgstc"
|
||||||
:items="notifications"
|
|
||||||
:no-gap="true"
|
:no-gap="true"
|
||||||
>
|
>
|
||||||
|
<XNotificationFolded
|
||||||
|
v-if="isFoldedNotification(notification)"
|
||||||
|
:key="'nf-' + notification.id"
|
||||||
|
:notification="notification"
|
||||||
|
:with-time="true"
|
||||||
|
:full="true"
|
||||||
|
class="_panel notification"
|
||||||
|
/>
|
||||||
<XNote
|
<XNote
|
||||||
v-if="isNoteNotification(notification)"
|
v-else-if="isNoteNotification(notification)"
|
||||||
:key="'nn-' + notification.id"
|
:key="'nn-' + notification.id"
|
||||||
:note="notification.note"
|
:note="notification.note"
|
||||||
:collapsed-reply="
|
:collapsed-reply="
|
||||||
|
@ -48,11 +56,15 @@ import MkPagination, {
|
||||||
type MkPaginationType,
|
type MkPaginationType,
|
||||||
} from "@/components/MkPagination.vue";
|
} from "@/components/MkPagination.vue";
|
||||||
import XNotification from "@/components/MkNotification.vue";
|
import XNotification from "@/components/MkNotification.vue";
|
||||||
|
import XNotificationFolded from "@/components/MkNotificationFolded.vue";
|
||||||
import XList from "@/components/MkDateSeparatedList.vue";
|
import XList from "@/components/MkDateSeparatedList.vue";
|
||||||
import XNote from "@/components/MkNote.vue";
|
import XNote from "@/components/MkNote.vue";
|
||||||
import { useStream } from "@/stream";
|
import { useStream } from "@/stream";
|
||||||
import { me } from "@/me";
|
import { me } from "@/me";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
|
import type { NotificationFolded } from "@/types/notification";
|
||||||
|
import { foldNotifications } from "@/scripts/fold";
|
||||||
|
import { defaultStore } from "@/store";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
includeTypes?: (typeof notificationTypes)[number][];
|
includeTypes?: (typeof notificationTypes)[number][];
|
||||||
|
@ -63,9 +75,12 @@ const stream = useStream();
|
||||||
|
|
||||||
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
|
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
|
||||||
|
|
||||||
|
const FETCH_LIMIT = 50;
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
endpoint: "i/notifications" as const,
|
endpoint: "i/notifications" as const,
|
||||||
limit: 10,
|
limit: FETCH_LIMIT,
|
||||||
|
secondFetchLimit: FETCH_LIMIT,
|
||||||
params: computed(() => ({
|
params: computed(() => ({
|
||||||
includeTypes: props.includeTypes ?? undefined,
|
includeTypes: props.includeTypes ?? undefined,
|
||||||
excludeTypes: props.includeTypes ? undefined : me?.mutingNotificationTypes,
|
excludeTypes: props.includeTypes ? undefined : me?.mutingNotificationTypes,
|
||||||
|
@ -81,6 +96,11 @@ function isNoteNotification(
|
||||||
| entities.MentionNotification {
|
| entities.MentionNotification {
|
||||||
return n.type === "reply" || n.type === "quote" || n.type === "mention";
|
return n.type === "reply" || n.type === "quote" || n.type === "mention";
|
||||||
}
|
}
|
||||||
|
function isFoldedNotification(
|
||||||
|
n: NotificationFolded | entities.Notification,
|
||||||
|
): n is NotificationFolded {
|
||||||
|
return "folded" in n;
|
||||||
|
}
|
||||||
|
|
||||||
const onNotification = (notification: entities.Notification) => {
|
const onNotification = (notification: entities.Notification) => {
|
||||||
const isMuted = props.includeTypes
|
const isMuted = props.includeTypes
|
||||||
|
@ -102,6 +122,14 @@ const onNotification = (notification: entities.Notification) => {
|
||||||
|
|
||||||
let connection: StreamTypes.ChannelOf<"main"> | undefined;
|
let connection: StreamTypes.ChannelOf<"main"> | undefined;
|
||||||
|
|
||||||
|
function convertNotification(n: entities.Notification[]) {
|
||||||
|
if (defaultStore.state.foldNotification) {
|
||||||
|
return foldNotifications(n, FETCH_LIMIT);
|
||||||
|
} else {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connection = stream.useChannel("main");
|
connection = stream.useChannel("main");
|
||||||
connection.on("notification", onNotification);
|
connection.on("notification", onNotification);
|
||||||
|
|
|
@ -117,6 +117,7 @@ export type PagingKey = PagingKeyOf<any>;
|
||||||
export interface Paging<E extends PagingKey = PagingKey> {
|
export interface Paging<E extends PagingKey = PagingKey> {
|
||||||
endpoint: E;
|
endpoint: E;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
secondFetchLimit?: number;
|
||||||
params?: Endpoints[E]["req"] | ComputedRef<Endpoints[E]["req"]>;
|
params?: Endpoints[E]["req"] | ComputedRef<Endpoints[E]["req"]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -141,7 +142,7 @@ export interface Paging<E extends PagingKey = PagingKey> {
|
||||||
|
|
||||||
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
|
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
|
||||||
|
|
||||||
const SECOND_FETCH_LIMIT = 30;
|
const SECOND_FETCH_LIMIT_DEFAULT = 30;
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -285,7 +286,8 @@ const fetchMore = async (): Promise<void> => {
|
||||||
await os
|
await os
|
||||||
.api(props.pagination.endpoint, {
|
.api(props.pagination.endpoint, {
|
||||||
...params,
|
...params,
|
||||||
limit: SECOND_FETCH_LIMIT + 1,
|
limit:
|
||||||
|
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
|
||||||
...(props.pagination.offsetMode
|
...(props.pagination.offsetMode
|
||||||
? {
|
? {
|
||||||
offset: offset.value,
|
offset: offset.value,
|
||||||
|
@ -312,7 +314,10 @@ const fetchMore = async (): Promise<void> => {
|
||||||
if (i === 10) item._shouldInsertAd_ = true;
|
if (i === 10) item._shouldInsertAd_ = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (res.length > SECOND_FETCH_LIMIT) {
|
if (
|
||||||
|
res.length >
|
||||||
|
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
|
||||||
|
) {
|
||||||
res.pop();
|
res.pop();
|
||||||
items.value = props.pagination.reversed
|
items.value = props.pagination.reversed
|
||||||
? res.toReversed().concat(items.value)
|
? res.toReversed().concat(items.value)
|
||||||
|
@ -346,7 +351,8 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||||
await os
|
await os
|
||||||
.api(props.pagination.endpoint, {
|
.api(props.pagination.endpoint, {
|
||||||
...params,
|
...params,
|
||||||
limit: SECOND_FETCH_LIMIT + 1,
|
limit:
|
||||||
|
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
|
||||||
...(props.pagination.offsetMode
|
...(props.pagination.offsetMode
|
||||||
? {
|
? {
|
||||||
offset: offset.value,
|
offset: offset.value,
|
||||||
|
@ -361,7 +367,10 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||||
})
|
})
|
||||||
.then(
|
.then(
|
||||||
(res: Item[]) => {
|
(res: Item[]) => {
|
||||||
if (res.length > SECOND_FETCH_LIMIT) {
|
if (
|
||||||
|
res.length >
|
||||||
|
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
|
||||||
|
) {
|
||||||
res.pop();
|
res.pop();
|
||||||
items.value = props.pagination.reversed
|
items.value = props.pagination.reversed
|
||||||
? res.toReversed().concat(items.value)
|
? res.toReversed().concat(items.value)
|
||||||
|
|
|
@ -327,6 +327,13 @@
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection>
|
||||||
|
<template #label>{{ i18n.ts.experimental }}</template>
|
||||||
|
<FormSwitch v-model="foldNotification" class="_formBlock">{{
|
||||||
|
i18n.ts.foldNotification
|
||||||
|
}}</FormSwitch>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label>{{ i18n.ts.forMobile }}</template>
|
<template #label>{{ i18n.ts.forMobile }}</template>
|
||||||
<FormSwitch
|
<FormSwitch
|
||||||
|
@ -542,6 +549,9 @@ const showAddFileDescriptionAtFirstPost = computed(
|
||||||
const autocorrectNoteLanguage = computed(
|
const autocorrectNoteLanguage = computed(
|
||||||
defaultStore.makeGetterSetter("autocorrectNoteLanguage"),
|
defaultStore.makeGetterSetter("autocorrectNoteLanguage"),
|
||||||
);
|
);
|
||||||
|
const foldNotification = computed(
|
||||||
|
defaultStore.makeGetterSetter("foldNotification"),
|
||||||
|
);
|
||||||
|
|
||||||
// This feature (along with injectPromo) is currently disabled
|
// This feature (along with injectPromo) is currently disabled
|
||||||
// function onChangeInjectFeaturedNote(v) {
|
// function onChangeInjectFeaturedNote(v) {
|
||||||
|
@ -610,6 +620,7 @@ watch(
|
||||||
enableTimelineStreaming,
|
enableTimelineStreaming,
|
||||||
enablePullToRefresh,
|
enablePullToRefresh,
|
||||||
pullToRefreshThreshold,
|
pullToRefreshThreshold,
|
||||||
|
foldNotification,
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await reloadAsk();
|
await reloadAsk();
|
||||||
|
|
100
packages/client/src/scripts/fold.ts
Normal file
100
packages/client/src/scripts/fold.ts
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import type {
|
||||||
|
FoldableNotification,
|
||||||
|
NotificationFolded,
|
||||||
|
} from "@/types/notification";
|
||||||
|
import type { entities } from "firefish-js";
|
||||||
|
|
||||||
|
interface FoldOption {
|
||||||
|
/** If items length is 1, skip aggregation */
|
||||||
|
/** @default true */
|
||||||
|
skipSingleElement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold similar content
|
||||||
|
* @param ns items to fold
|
||||||
|
* @param fetch_limit fetch limit of pagination. items will be divided into subarrays with this limit as the length.
|
||||||
|
* @param classfier Classify the given item into a certain category (return a string representing the category)
|
||||||
|
* @param aggregator Aggregate items of the given class into itemfolded
|
||||||
|
* @returns folded items
|
||||||
|
*/
|
||||||
|
export function foldItems<ItemFolded, Item>(
|
||||||
|
ns: Item[],
|
||||||
|
fetch_limit: number,
|
||||||
|
classfier: (n: Item, index: number) => string,
|
||||||
|
aggregator: (ns: Item[], key: string) => ItemFolded,
|
||||||
|
_options?: FoldOption,
|
||||||
|
) {
|
||||||
|
let res: (ItemFolded | Item)[] = [];
|
||||||
|
|
||||||
|
const options: FoldOption = _options ?? {};
|
||||||
|
options.skipSingleElement ??= true;
|
||||||
|
|
||||||
|
for (let i = 0; i < ns.length; i += fetch_limit) {
|
||||||
|
const toFold = ns.slice(i, i + fetch_limit);
|
||||||
|
const toAppendKeys: string[] = [];
|
||||||
|
const foldMap = new Map<string, Item[]>();
|
||||||
|
|
||||||
|
for (const [index, n] of toFold.entries()) {
|
||||||
|
const key = classfier(n, index);
|
||||||
|
const arr = foldMap.get(key);
|
||||||
|
if (arr != null) {
|
||||||
|
arr.push(n);
|
||||||
|
} else {
|
||||||
|
foldMap.set(key, [n]);
|
||||||
|
toAppendKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res = res.concat(
|
||||||
|
toAppendKeys.map((key) => {
|
||||||
|
const arr = foldMap.get(key)!;
|
||||||
|
if (arr?.length === 1 && options?.skipSingleElement) {
|
||||||
|
return arr[0];
|
||||||
|
}
|
||||||
|
return aggregator(arr, key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function foldNotifications(
|
||||||
|
ns: entities.Notification[],
|
||||||
|
fetch_limit: number,
|
||||||
|
) {
|
||||||
|
return foldItems(
|
||||||
|
ns,
|
||||||
|
fetch_limit,
|
||||||
|
(n) => {
|
||||||
|
switch (n.type) {
|
||||||
|
case "renote":
|
||||||
|
return `renote-of:${n.note.renote.id}`;
|
||||||
|
case "reaction":
|
||||||
|
return `reaction:${n.reaction}:of:${n.note.id}`;
|
||||||
|
default: {
|
||||||
|
return `${n.id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(ns) => {
|
||||||
|
const represent = ns[0];
|
||||||
|
function check(
|
||||||
|
ns: entities.Notification[],
|
||||||
|
): ns is FoldableNotification[] {
|
||||||
|
return represent.type === "renote" || represent.type === "reaction";
|
||||||
|
}
|
||||||
|
if (!check(ns)) {
|
||||||
|
return represent;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...represent,
|
||||||
|
folded: true,
|
||||||
|
userIds: ns.map((nn) => nn.userId),
|
||||||
|
users: ns.map((nn) => nn.user),
|
||||||
|
notifications: ns!,
|
||||||
|
} as NotificationFolded;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -450,6 +450,10 @@ export const defaultStore = markRaw(
|
||||||
where: "account",
|
where: "account",
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
foldNotification: {
|
||||||
|
where: "device",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
31
packages/client/src/types/notification.ts
Normal file
31
packages/client/src/types/notification.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import type { entities } from "firefish-js";
|
||||||
|
|
||||||
|
export type FoldableNotification =
|
||||||
|
| entities.RenoteNotification
|
||||||
|
| entities.ReactionNotification;
|
||||||
|
|
||||||
|
type Fold<T extends FoldableNotification> = {
|
||||||
|
id: string;
|
||||||
|
type: T["type"];
|
||||||
|
createdAt: T["createdAt"];
|
||||||
|
note: T["note"];
|
||||||
|
folded: true;
|
||||||
|
userIds: entities.User["id"][];
|
||||||
|
users: entities.User[];
|
||||||
|
notifications: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenoteNotificationFolded = Fold<entities.RenoteNotification>;
|
||||||
|
|
||||||
|
export type ReactionNotificationFolded = Fold<entities.ReactionNotification> & {
|
||||||
|
reaction: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetNotificationFoldedType<T extends FoldableNotification> =
|
||||||
|
T["type"] extends "renote"
|
||||||
|
? RenoteNotificationFolded
|
||||||
|
: ReactionNotificationFolded;
|
||||||
|
|
||||||
|
export type NotificationFolded =
|
||||||
|
| RenoteNotificationFolded
|
||||||
|
| ReactionNotificationFolded;
|
Loading…
Reference in a new issue