diff --git a/fe_calckey/frontend/client/src/components/MagAvatars.vue b/fe_calckey/frontend/client/src/components/MagAvatars.vue index 899f8e8..2f267d2 100644 --- a/fe_calckey/frontend/client/src/components/MagAvatars.vue +++ b/fe_calckey/frontend/client/src/components/MagAvatars.vue @@ -7,42 +7,31 @@ diff --git a/fe_calckey/frontend/client/src/components/MagNotifications.vue b/fe_calckey/frontend/client/src/components/MagNotifications.vue index 711936a..ba3ec85 100644 --- a/fe_calckey/frontend/client/src/components/MagNotifications.vue +++ b/fe_calckey/frontend/client/src/components/MagNotifications.vue @@ -1,89 +1,136 @@ - +
+ + {{ i18n.ts.loadMore }} + + +
+ + +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> => { + 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 => { + 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 => { + 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, +}); +