561 lines
18 KiB
Vue
561 lines
18 KiB
Vue
<template>
|
|
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
|
<MkLoading v-if="initialFetching" />
|
|
|
|
<MkError v-else-if="error" @retry="init()" />
|
|
|
|
<div v-else-if="empty" key="_empty_" class="empty">
|
|
<slot name="empty">
|
|
<div class="_fullinfo">
|
|
<img
|
|
src="/static-assets/badges/info.png"
|
|
class="_ghost"
|
|
alt="Info"
|
|
/>
|
|
<div>{{ i18n.ts.noNotifications }}</div>
|
|
</div>
|
|
</slot>
|
|
</div>
|
|
|
|
<div v-else ref="rootEl" class="list">
|
|
<XList
|
|
v-slot="{ item: notificationGroup }"
|
|
class="elsfgstc"
|
|
:items="items"
|
|
:no-gap="true"
|
|
>
|
|
<template v-if="notificationGroup.type === 'single'">
|
|
<XNote
|
|
v-if="
|
|
notificationGroup.value.note &&
|
|
(notificationGroup.value.type === 'Quote' ||
|
|
notificationGroup.value.type === 'Mention' ||
|
|
notificationGroup.value.type === 'Reply')
|
|
"
|
|
:key="'noteNotif' + notificationGroup.value.id"
|
|
:note="notificationGroup.value.note"
|
|
:collapsedReply="
|
|
!!notificationGroup.value.note.parent_note
|
|
"
|
|
/>
|
|
<XNotification
|
|
v-else
|
|
:key="'basicNotif' + notificationGroup.value.id"
|
|
:notification="notificationGroup.value"
|
|
:with-time="true"
|
|
:full="true"
|
|
class="_panel notification"
|
|
/>
|
|
</template>
|
|
<XNotificationGroup
|
|
v-else-if="notificationGroup.type === 'group'"
|
|
:notificationGroup="notificationGroup"
|
|
:with-time="true"
|
|
:full="true"
|
|
/>
|
|
</XList>
|
|
<div v-show="next" key="_more_" class="cxiknjgy _gap">
|
|
<MkButton
|
|
v-if="!moreFetching"
|
|
v-appear="
|
|
$store.state.enableInfiniteScroll ? fetchMore : null
|
|
"
|
|
class="button"
|
|
:disabled="moreFetching"
|
|
:style="{ cursor: moreFetching ? 'wait' : 'pointer' }"
|
|
primary
|
|
@click="fetchMore"
|
|
>
|
|
{{ i18n.ts.loadMore }}
|
|
</MkButton>
|
|
<MkLoading v-else class="loading" />
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import {
|
|
computed,
|
|
onActivated,
|
|
onDeactivated,
|
|
onMounted,
|
|
onUnmounted,
|
|
ref,
|
|
watch,
|
|
} from "vue";
|
|
import XNotification from "@/components/MagNotification.vue";
|
|
import XNotificationGroup from "@/components/MagNotificationGroup.vue";
|
|
import XList from "@/components/MkDateSeparatedList.vue";
|
|
import XNote from "@/components/MagNote.vue";
|
|
import { magStream, stream } from "@/stream";
|
|
import { $i } from "@/account";
|
|
import { i18n } from "@/i18n";
|
|
import * as os from "@/os";
|
|
import { isTopVisible, onScrollTop } from "@/scripts/scroll";
|
|
import MkButton from "@/components/MkButton.vue";
|
|
import { endpoints, packed, PaginatedResult, types } from "magnetar-common";
|
|
import { SpanFilter } from "magnetar-common/built/types/SpanFilter";
|
|
import { ChannelEvent } from "magnetar-common/built/types";
|
|
import { Connection } from "calckey-js/built/streaming";
|
|
import { Channels } from "calckey-js";
|
|
import { magEffectiveNote } from "@/scripts-mag/mag-util";
|
|
|
|
const props = defineProps<{
|
|
includeTypes?: types.NotificationType[];
|
|
unreadOnly?: boolean;
|
|
}>();
|
|
|
|
type NotificationGroup = (
|
|
| {
|
|
type: "group";
|
|
value: packed.PackNotification[];
|
|
users: packed.PackUserBase[];
|
|
}
|
|
| { type: "single"; value: packed.PackNotification }
|
|
) & { id: string; created_at: string };
|
|
|
|
const rootEl = ref<HTMLElement>();
|
|
const items = ref<NotificationGroup[]>([]);
|
|
const queue = ref<packed.PackNotification[]>([]);
|
|
const initialFetching = ref(true);
|
|
const moreFetching = ref(false);
|
|
const next = ref<URL | null>(null);
|
|
const isBackTop = ref(false);
|
|
const empty = computed(() => items.value.length === 0);
|
|
const error = ref(false);
|
|
const displayLimit = 50;
|
|
const groupSizeLimit = 12;
|
|
const allowedGroups: types.NotificationType[] = ["Reaction", "Renote"];
|
|
|
|
watch(
|
|
() => props.includeTypes,
|
|
() => reload()
|
|
);
|
|
|
|
const onNotification = (notification: packed.PackNotification) => {
|
|
const isMuted = props.includeTypes
|
|
? !props.includeTypes.includes(notification.type)
|
|
: $i?.mutingNotificationTypes?.includes(notification.type);
|
|
if (isMuted || document.visibilityState === "visible") {
|
|
stream.send("readNotification", {
|
|
id: notification.id,
|
|
});
|
|
}
|
|
|
|
if (!isMuted) {
|
|
prepend({
|
|
...notification,
|
|
is_read: document.visibilityState === "visible",
|
|
});
|
|
}
|
|
};
|
|
|
|
let notifStream: ((e: ChannelEvent) => void) | undefined;
|
|
let connection: Connection<Channels["main"]>;
|
|
|
|
onMounted(() => {
|
|
notifStream = magStream.useFiltered("Notification", onNotification);
|
|
|
|
connection = stream.useChannel("main");
|
|
connection.on("readAllNotifications", () => {
|
|
for (const item of queue.value) {
|
|
item.is_read = true;
|
|
}
|
|
|
|
for (const item of items.value) {
|
|
if (item.type === "single") {
|
|
item.value.is_read = true;
|
|
} else {
|
|
item.value.forEach((it) => {
|
|
it.is_read = true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
connection.on("readNotifications", (notificationIds: string[]) => {
|
|
for (const item of queue.value) {
|
|
if (notificationIds.includes(item.id)) {
|
|
item.is_read = true;
|
|
}
|
|
}
|
|
|
|
for (const item of items.value) {
|
|
if (item.type === "single") {
|
|
if (notificationIds.includes(item.value.id)) {
|
|
item.value.is_read = true;
|
|
}
|
|
} else {
|
|
item.value
|
|
.filter((it) => notificationIds.includes(it.id))
|
|
.forEach((it) => {
|
|
it.is_read = true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (connection) connection.dispose();
|
|
if (notifStream) magStream.off("message", notifStream);
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
(ev: "queue", count: number): void;
|
|
}>();
|
|
|
|
// Let's establish some rules for how notifications should be grouped:
|
|
// 1. Incoming notifications can form a group or be added to an existing group, up to a limit
|
|
// 2. [DONE] Initially loaded notifications can form a group
|
|
// 3. [DONE] Notifications are not grouped across loading boundaries
|
|
// 4. Notification groups can only be appended to when scrolled to the top
|
|
// 5. [MAYBE] Items in the queue do not cross the original boundary when prepended
|
|
// 5. [MAYBE] Items that would be be pushed out by another item CAN break boundaries, but only with adjacent items
|
|
|
|
const groupIntra = (
|
|
notifications: packed.PackNotification[]
|
|
): NotificationGroup[] => {
|
|
const x = notifications
|
|
.map(
|
|
(n) =>
|
|
({
|
|
type: "single",
|
|
id: n.id,
|
|
created_at: n.created_at,
|
|
value: n,
|
|
} as NotificationGroup)
|
|
)
|
|
.map((g) => [g])
|
|
.reduce(
|
|
(
|
|
prev: NotificationGroup[],
|
|
[curr]: NotificationGroup[]
|
|
): NotificationGroup[] => {
|
|
const currGroup = curr as NotificationGroup & {
|
|
type: "single";
|
|
};
|
|
|
|
if (
|
|
!allowedGroups.includes(currGroup.value.type) ||
|
|
!("note" in currGroup.value)
|
|
) {
|
|
return [...prev, currGroup];
|
|
}
|
|
|
|
const currNoteGroup = currGroup as typeof currGroup & {
|
|
value: { note: any };
|
|
};
|
|
|
|
for (let i = prev.length - 1; i >= 0; i--) {
|
|
const p = prev[i];
|
|
if (
|
|
p.type === "single" &&
|
|
"note" in p.value &&
|
|
p.value.type === currNoteGroup.value.type &&
|
|
magEffectiveNote(p.value.note).id ===
|
|
magEffectiveNote(currNoteGroup.value.note).id
|
|
) {
|
|
const itemsNew = [p.value, currNoteGroup.value];
|
|
prev[i] = {
|
|
...p,
|
|
type: "group",
|
|
value: itemsNew,
|
|
users: itemsNew.map((n) => {
|
|
switch (n.type) {
|
|
case "Renote":
|
|
return n.note.user;
|
|
case "Reaction":
|
|
return n.user;
|
|
default:
|
|
// Unreachable
|
|
return null as never as packed.PackUserBase;
|
|
}
|
|
}),
|
|
};
|
|
|
|
return prev;
|
|
} else if (
|
|
p.type === "group" &&
|
|
p.value.length < groupSizeLimit &&
|
|
p.value.some(
|
|
(n) =>
|
|
"note" in n &&
|
|
magEffectiveNote(n.note).id ===
|
|
magEffectiveNote(currNoteGroup.value.note)
|
|
.id &&
|
|
n.type === currNoteGroup.value.type
|
|
)
|
|
) {
|
|
p.value = [...p.value, currNoteGroup.value];
|
|
p.users = p.value.map((n) => {
|
|
switch (n.type) {
|
|
case "Renote":
|
|
return n.note.user;
|
|
case "Reaction":
|
|
return n.user;
|
|
default:
|
|
// Unreachable
|
|
return null as never as packed.PackUserBase;
|
|
}
|
|
});
|
|
|
|
return prev;
|
|
}
|
|
}
|
|
|
|
return [...prev, currGroup];
|
|
},
|
|
[] as NotificationGroup[]
|
|
);
|
|
|
|
return x;
|
|
};
|
|
|
|
const fetch = async (
|
|
pagination: SpanFilter
|
|
): Promise<PaginatedResult<packed.PackNotification[]>> => {
|
|
return os.magApi(
|
|
endpoints.GetNotifications,
|
|
{
|
|
include_types: props.includeTypes ?? undefined,
|
|
exclude_types: props.includeTypes
|
|
? undefined
|
|
: ($i?.mutingNotificationTypes as types.NotificationType[]),
|
|
unread_only: props.unreadOnly,
|
|
pagination,
|
|
limit: displayLimit,
|
|
},
|
|
{},
|
|
undefined
|
|
);
|
|
};
|
|
|
|
const init = async (): Promise<void> => {
|
|
queue.value = [];
|
|
initialFetching.value = true;
|
|
|
|
fetch({}).then(
|
|
(res) => {
|
|
items.value = groupIntra(res.data);
|
|
next.value = res.next ?? null;
|
|
error.value = false;
|
|
initialFetching.value = false;
|
|
},
|
|
(err) => {
|
|
error.value = true;
|
|
initialFetching.value = false;
|
|
}
|
|
);
|
|
};
|
|
|
|
const reload = (): void => {
|
|
items.value = [];
|
|
init();
|
|
};
|
|
|
|
const fetchMore = async (): Promise<void> => {
|
|
if (
|
|
!next.value ||
|
|
initialFetching.value ||
|
|
moreFetching.value ||
|
|
items.value.length === 0 ||
|
|
!next.value.searchParams.has("pagination")
|
|
)
|
|
return;
|
|
|
|
const nextCursorRaw = next.value.searchParams.get("pagination") as string;
|
|
const nextCursorDecoded = {
|
|
...Object.fromEntries(new URLSearchParams(nextCursorRaw).entries()),
|
|
} as types.PaginationShape["pagination"];
|
|
|
|
moreFetching.value = true;
|
|
|
|
await fetch(nextCursorDecoded).then(
|
|
(res) => {
|
|
items.value = items.value.concat(groupIntra(res.data));
|
|
next.value = res.next ?? null;
|
|
moreFetching.value = false;
|
|
},
|
|
(err) => {
|
|
moreFetching.value = false;
|
|
}
|
|
);
|
|
};
|
|
|
|
const prepend = (item: packed.PackNotification): void => {
|
|
if (!rootEl.value) {
|
|
items.value.unshift({
|
|
type: "single",
|
|
value: item,
|
|
id: item.id,
|
|
created_at: item.created_at,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const isTop =
|
|
isBackTop.value ||
|
|
(document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
|
|
|
|
if (isTop) {
|
|
// Prepend the item
|
|
|
|
const currNotification = item;
|
|
const foundGroup = items.value.findIndex((p) => {
|
|
if (
|
|
!allowedGroups.includes(currNotification.type) ||
|
|
!("note" in currNotification)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
p.type === "single" &&
|
|
"note" in p.value &&
|
|
p.value.type === currNotification.type &&
|
|
magEffectiveNote(p.value.note).id ===
|
|
magEffectiveNote(currNotification.note).id
|
|
) {
|
|
return true;
|
|
} else if (
|
|
p.type === "group" &&
|
|
p.value.length < groupSizeLimit &&
|
|
p.value.some(
|
|
(n) =>
|
|
"note" in n &&
|
|
magEffectiveNote(n.note).id ===
|
|
magEffectiveNote(currNotification.note).id &&
|
|
n.type === currNotification.type
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if (foundGroup !== -1) {
|
|
const [group] = items.value.splice(foundGroup, 1);
|
|
|
|
if (group.type === "single") {
|
|
const itemsNew = [currNotification, group.value];
|
|
items.value = [
|
|
{
|
|
id: currNotification.id,
|
|
type: "group",
|
|
created_at: currNotification.created_at,
|
|
value: itemsNew,
|
|
users: itemsNew.map((n) => {
|
|
switch (n.type) {
|
|
case "Renote":
|
|
return n.note.user;
|
|
case "Reaction":
|
|
return n.user;
|
|
default:
|
|
// Unreachable
|
|
return null as never as packed.PackUserBase;
|
|
}
|
|
}),
|
|
},
|
|
...items.value,
|
|
];
|
|
} else if (group.type === "group") {
|
|
group.id = currNotification.id;
|
|
group.created_at = currNotification.created_at;
|
|
group.value = [currNotification, ...group.value];
|
|
group.users = group.value.map((n) => {
|
|
switch (n.type) {
|
|
case "Renote":
|
|
return n.note.user;
|
|
case "Reaction":
|
|
return n.user;
|
|
default:
|
|
// Unreachable
|
|
return null as never as packed.PackUserBase;
|
|
}
|
|
});
|
|
|
|
items.value = [group, ...items.value];
|
|
}
|
|
} else {
|
|
items.value = [
|
|
{
|
|
id: item.id,
|
|
type: "single" as "single",
|
|
created_at: item.created_at,
|
|
value: item,
|
|
},
|
|
...items.value,
|
|
].slice(0, displayLimit);
|
|
}
|
|
} else {
|
|
if (!queue.value.length) {
|
|
onScrollTop(rootEl.value, () => {
|
|
items.value = [
|
|
...groupIntra(queue.value.reverse()),
|
|
...items.value,
|
|
].slice(0, displayLimit);
|
|
queue.value = [];
|
|
});
|
|
}
|
|
|
|
queue.value = [...queue.value, item].slice(-displayLimit);
|
|
}
|
|
};
|
|
|
|
watch(
|
|
queue,
|
|
(a, b) => {
|
|
if (a.length === 0 && b.length === 0) return;
|
|
emit("queue", queue.value.length);
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
init();
|
|
|
|
onActivated(() => {
|
|
isBackTop.value = false;
|
|
});
|
|
|
|
onDeactivated(() => {
|
|
isBackTop.value = window.scrollY === 0;
|
|
});
|
|
|
|
defineExpose({
|
|
items,
|
|
queue,
|
|
reload,
|
|
});
|
|
</script>
|
|
<style lang="scss" scoped>
|
|
.elsfgstc {
|
|
background: var(--panel);
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.125s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.cxiknjgy {
|
|
> .button {
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
}
|
|
.list > :deep(._button) {
|
|
margin-inline: auto;
|
|
margin-bottom: 16px;
|
|
&:last-of-type:not(:first-child) {
|
|
margin-top: 16px;
|
|
}
|
|
}
|
|
</style>
|