Implemented client-side notification grouping
ci/woodpecker/push/ociImagePush Pipeline is running Details

This commit is contained in:
Natty 2024-02-29 23:40:27 +01:00
parent 459f944243
commit 1394557f81
Signed by: natty
GPG Key ID: BF6CB659ADEE60EC
3 changed files with 867 additions and 111 deletions

View File

@ -7,42 +7,31 @@
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from "vue";
import { ref, watch } from "vue";
import * as os from "@/os";
import { endpoints, packed } from "magnetar-common";
const props = defineProps<{
userIds: string[];
userIds: (string | packed.PackUserBase)[];
}>();
const users = ref<packed.PackUserBase[]>([]);
onMounted(async () => {
users.value = await os
.magApi(
endpoints.GetManyUsersById,
{
id: props.userIds,
},
{}
)
.then((p) => p.filter((u) => u !== null).map((u) => u!));
watch(
() => props.userIds,
async (userIds) => {
users.value = await os
.magApi(
endpoints.GetManyUsersById,
{
id: userIds,
},
{}
)
.then((p) => p.filter((u) => u !== null).map((u) => u!));
}
);
});
watch(
() => props.userIds,
async (userIds) => {
users.value = await os
.magApi(
endpoints.GetManyUsersById,
{
id: userIds.map((u) => (typeof u === "string" ? u : u.id)),
},
{}
)
.then((p) => p.filter((u) => u !== null).map((u) => u!));
},
{ immediate: true, deep: true }
);
</script>
<style lang="scss">

View File

@ -0,0 +1,369 @@
<template>
<div
ref="elRef"
v-size="{ max: [500, 450] }"
class="qglefbjs notification"
:class="firstNotification.type"
>
<div class="tail">
<header>
<div class="name-wrapper">
<template v-for="notif in notificationGroup.value">
<MkA
v-if="notif.type === 'Renote'"
v-user-preview="notif.note.user.id"
class="name"
:to="userPage(notif.note.user)"
><MkUserName :user="notif.note.user"
/></MkA>
<MkA
v-else-if="notif.type === 'Reaction'"
v-user-preview="notif.user.id"
class="name"
:to="userPage(notif.user)"
><MkUserName :user="notif.user"
/></MkA>
</template>
</div>
<MkTime
v-if="withTime"
:time="firstNotification.created_at"
class="time"
/>
</header>
<MkA
v-if="firstNotification.type === 'Reaction'"
class="text"
:to="notePage(firstNotification.note)"
:title="getNoteSummary(firstNotification.note)"
>
<span>{{ i18n.ts._notification.reacted }}</span>
<i class="ph-quotes ph-fill ph-lg"></i>
<Mfm
:text="getNoteSummary(firstNotification.note)"
:plain="true"
:nowrap="!full"
:custom-emojis="firstNotification.note.emojis"
/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
<MkA
v-if="firstNotification.type === 'Renote'"
class="text"
:to="notePage(firstNotification.note.renoted_note!)"
:title="getNoteSummary(firstNotification.note.renoted_note!)"
>
<span>{{ i18n.ts._notification.renoted }}</span>
<i class="ph-quotes ph-fill ph-lg"></i>
<Mfm
:text="getNoteSummary(firstNotification.note.renoted_note!)"
:plain="true"
:nowrap="!full"
:custom-emojis="firstNotification.note.renoted_note!.emojis"
/>
<i class="ph-quotes ph-fill ph-lg"></i>
</MkA>
</div>
<div class="head-wrapper">
<div class="head" v-for="notif in notificationGroup.value">
<MagAvatar
v-if="notif.type === 'Renote'"
class="icon"
:user="notif.note.user"
/>
<MagAvatar
v-else-if="firstNotification.type === 'Reaction'"
class="icon"
:user="firstNotification.user"
/>
<div class="sub-icon" :class="notif.type">
<i
v-if="notif.type === 'Renote'"
class="ph-repeat ph-bold"
></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MagEmoji
v-else-if="
showEmojiReactions && notif.type === 'Reaction'
"
:ref="(el) => hookTooltip(el, notif)"
:emoji="notif.reaction"
:is-reaction="true"
:normal="true"
:no-style="true"
/>
<MagEmoji
v-else-if="
!showEmojiReactions && notif.type === 'Reaction'
"
:emoji="defaultReaction"
:is-reaction="true"
:normal="true"
:no-style="true"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
ComponentPublicInstance,
Ref,
onMounted,
onUnmounted,
ref,
watch,
} from "vue";
import XReactionTooltip from "@/components/MkReactionTooltip.vue";
import { getNoteSummary } from "@/scripts/get-note-summary";
import { notePage } from "@/filters/note";
import * as os from "@/os";
import { stream } from "@/stream";
import { useTooltip } from "@/scripts/use-tooltip";
import { defaultStore } from "@/store";
import { instance } from "@/instance";
import { packed } from "magnetar-common";
import { i18n } from "@/i18n";
import { userPage } from "@/filters/user";
const props = withDefaults(
defineProps<{
notificationGroup: {
type: "group";
value: packed.PackNotification[];
users: packed.PackUserBase[];
id: string;
created_at: string;
};
withTime?: boolean;
full?: boolean;
}>(),
{
withTime: false,
full: false,
}
);
const firstNotification = ref(props.notificationGroup.value[0]);
watch(
() => props.notificationGroup.value[0],
(n) => {
firstNotification.value = n;
},
{ deep: true }
);
const emit = defineEmits(["refresh"]);
const elRef = ref<HTMLElement>();
const reactionRefs = ref<Ref<HTMLElement | ComponentPublicInstance>[]>([]);
const showEmojiReactions =
defaultStore.state.enableEmojiReactions ||
defaultStore.state.showEmojisInReactionNotifications;
const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReaction)
? instance.defaultReaction
: "⭐";
let readObserver: IntersectionObserver | undefined;
let connection;
onMounted(() => {
if (!firstNotification.value.is_read && elRef.value) {
readObserver = new IntersectionObserver((entries, observer) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
stream.send(
"readNotifications",
props.notificationGroup.value.map((n) => n.id)
);
observer.disconnect();
});
readObserver.observe(elRef.value);
connection = stream.useChannel("main");
connection.on("readAllNotifications", () => readObserver!.disconnect());
watch(
() => firstNotification.value.is_read,
() => {
readObserver!.disconnect();
}
);
}
});
onUnmounted(() => {
if (readObserver) readObserver.disconnect();
if (connection) connection.dispose();
});
const hookTooltip = (
el: HTMLElement | ComponentPublicInstance | null,
notif: packed.PackNotification
) => {
if (el == null) return;
const elRef = ref(el);
useTooltip(elRef, (showing) => {
if (notif.type !== "Reaction") return;
os.popup(
XReactionTooltip,
{
showing,
reaction: notif.reaction,
targetElement:
"$el" in elRef.value ? elRef.value.$el : elRef.value,
},
{},
"closed"
);
});
reactionRefs.value.push(elRef);
};
</script>
<style lang="scss" scoped>
.qglefbjs {
position: relative;
box-sizing: border-box;
padding: 24px 32px;
font-size: 0.9em;
overflow-wrap: break-word;
display: flex;
contain: content;
flex-direction: column;
&.max-width_500px {
padding-block: 16px;
font-size: 0.9em;
}
&.max-width_450px {
padding: 12px 16px;
}
> .head-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
> .head {
position: relative;
top: 0;
flex-shrink: 0;
width: 42px;
height: 42px;
> .icon {
display: block;
width: 100%;
height: 100%;
border-radius: 6px;
}
> .sub-icon {
position: absolute;
z-index: 1;
bottom: -2px;
right: -2px;
width: 20px;
height: 20px;
box-sizing: border-box;
border-radius: 100%;
background: var(--panel);
box-shadow: 0 0 0 3px var(--panel);
font-size: 12px;
text-align: center;
&:empty {
display: none;
}
> * {
color: #fff;
width: 100%;
height: 100%;
}
&.Renote {
padding: 3px;
background: #31748f;
pointer-events: none;
}
}
}
}
> .tail {
flex: 1;
min-width: 0;
margin-block-end: 12px;
> header {
display: flex;
align-items: baseline;
white-space: nowrap;
> .name-wrapper {
display: flex;
flex-wrap: wrap;
align-items: baseline;
margin-inline-end: 10px;
gap: 0.2em;
> .name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
min-width: 0;
flex: 0 1 auto;
}
> .name:not(:last-child)::after {
content: ",";
}
}
> .time {
margin-left: auto;
font-size: 0.9em;
}
}
> .text {
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
> span:first-child {
opacity: 0.7;
&::after {
content: ": ";
}
}
> i {
vertical-align: super;
font-size: 50%;
opacity: 0.5;
}
> i:first-child {
margin-right: 4px;
}
> i:last-child {
margin-left: 4px;
}
}
}
}
</style>

View File

@ -1,89 +1,136 @@
<template>
<MagPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img
src="/static-assets/badges/info.png"
class="_ghost"
alt="Info"
/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</template>
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<MkLoading v-if="initialFetching" />
<template #items="{ items: notifications }">
<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: notification }"
v-slot="{ item: notificationGroup }"
class="elsfgstc"
:items="notifications"
:items="items"
:no-gap="true"
>
<XNote
v-if="
notification.note &&
(notification.type === 'Quote' ||
notification.type === 'Mention' ||
notification.type === 'Reply')
"
:key="'noteNotif' + notification.id"
:note="notification.note"
:collapsedReply="!!notification.note.parent_note"
/>
<XNotification
v-else
:key="'basicNotif' + notification.id"
:notification="notification"
<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"
class="_panel notification"
/>
</XList>
</template>
</MagPagination>
<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 { onMounted, onUnmounted, reactive, ref, watch } from "vue";
import type { ComponentExposed } from "vue-component-type-helpers";
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 MagPagination, { Paging } from "@/components/MagPagination.vue";
import { endpoints, packed, types } from "magnetar-common";
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;
}>();
const pagingComponent =
ref<
ComponentExposed<
typeof MagPagination<typeof endpoints.GetNotifications>
>
>();
type NotificationGroup = (
| {
type: "group";
value: packed.PackNotification[];
users: packed.PackUserBase[];
}
| { type: "single"; value: packed.PackNotification }
) & { id: string; created_at: string };
const pagination: Paging<typeof endpoints.GetNotifications> = reactive({
endpoint: endpoints.GetNotifications,
pathParams: {},
params: {
include_types: props.includeTypes ?? undefined,
exclude_types: props.includeTypes
? undefined
: $i?.mutingNotificationTypes,
unread_only: props.unreadOnly,
} as types.NotificationsReq,
});
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,
(types) => {
pagination.params!.include_types = types ?? undefined;
}
() => reload()
);
const onNotification = (notification: packed.PackNotification) => {
@ -97,49 +144,53 @@ const onNotification = (notification: packed.PackNotification) => {
}
if (!isMuted) {
pagingComponent.value?.prepend({
prepend({
...notification,
isRead: document.visibilityState === "visible",
is_read: document.visibilityState === "visible",
});
}
};
let notifStream: ((e: ChannelEvent) => void) | undefined;
let connection;
let connection: Connection<Channels["main"]>;
onMounted(() => {
notifStream = magStream.useFiltered("Notification", onNotification);
connection = stream.useChannel("main");
connection.on("readAllNotifications", () => {
if (pagingComponent.value) {
for (const item of pagingComponent.value.queue) {
item.is_read = true;
}
for (const item of pagingComponent.value.items) {
item.is_read = true;
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) => {
if (pagingComponent.value) {
for (let i = 0; i < pagingComponent.value.queue.length; i++) {
if (
notificationIds.includes(pagingComponent.value.queue[i].id)
) {
pagingComponent.value.queue[i].is_read = true;
}
connection.on("readNotifications", (notificationIds: string[]) => {
for (const item of queue.value) {
if (notificationIds.includes(item.id)) {
item.is_read = true;
}
for (
let i = 0;
i < (pagingComponent.value.items || []).length;
i++
) {
if (
notificationIds.includes(pagingComponent.value.items[i].id)
) {
pagingComponent.value.items[i].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;
});
}
}
});
@ -149,11 +200,358 @@ onUnmounted(() => {
if (connection) connection.dispose();
if (notifStream) magStream.off("message", notifStream);
});
</script>
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 = [
{
...group,
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;
}
}),
},
...items.value,
];
} else if (group.type === "group") {
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>