Compare commits
No commits in common. "1394557f811a9af5aec0ff7fbb425f695ed8f1c6" and "a169ebb004a97b2bd032f57d35d921a67d99e1ad" have entirely different histories.
1394557f81
...
a169ebb004
|
@ -7,16 +7,27 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
import { endpoints, packed } from "magnetar-common";
|
import { endpoints, packed } from "magnetar-common";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
userIds: (string | packed.PackUserBase)[];
|
userIds: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const users = ref<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(
|
watch(
|
||||||
() => props.userIds,
|
() => props.userIds,
|
||||||
async (userIds) => {
|
async (userIds) => {
|
||||||
|
@ -24,14 +35,14 @@ watch(
|
||||||
.magApi(
|
.magApi(
|
||||||
endpoints.GetManyUsersById,
|
endpoints.GetManyUsersById,
|
||||||
{
|
{
|
||||||
id: userIds.map((u) => (typeof u === "string" ? u : u.id)),
|
id: userIds,
|
||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
.then((p) => p.filter((u) => u !== null).map((u) => u!));
|
.then((p) => p.filter((u) => u !== null).map((u) => u!));
|
||||||
},
|
}
|
||||||
{ immediate: true, deep: true }
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -1,369 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,11 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
<MagPagination ref="pagingComponent" :pagination="pagination">
|
||||||
<MkLoading v-if="initialFetching" />
|
<template #empty>
|
||||||
|
|
||||||
<MkError v-else-if="error" @retry="init()" />
|
|
||||||
|
|
||||||
<div v-else-if="empty" key="_empty_" class="empty">
|
|
||||||
<slot name="empty">
|
|
||||||
<div class="_fullinfo">
|
<div class="_fullinfo">
|
||||||
<img
|
<img
|
||||||
src="/static-assets/badges/info.png"
|
src="/static-assets/badges/info.png"
|
||||||
|
@ -14,123 +9,81 @@
|
||||||
/>
|
/>
|
||||||
<div>{{ i18n.ts.noNotifications }}</div>
|
<div>{{ i18n.ts.noNotifications }}</div>
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else ref="rootEl" class="list">
|
<template #items="{ items: notifications }">
|
||||||
<XList
|
<XList
|
||||||
v-slot="{ item: notificationGroup }"
|
v-slot="{ item: notification }"
|
||||||
class="elsfgstc"
|
class="elsfgstc"
|
||||||
:items="items"
|
:items="notifications"
|
||||||
:no-gap="true"
|
:no-gap="true"
|
||||||
>
|
>
|
||||||
<template v-if="notificationGroup.type === 'single'">
|
|
||||||
<XNote
|
<XNote
|
||||||
v-if="
|
v-if="
|
||||||
notificationGroup.value.note &&
|
notification.note &&
|
||||||
(notificationGroup.value.type === 'Quote' ||
|
(notification.type === 'Quote' ||
|
||||||
notificationGroup.value.type === 'Mention' ||
|
notification.type === 'Mention' ||
|
||||||
notificationGroup.value.type === 'Reply')
|
notification.type === 'Reply')
|
||||||
"
|
|
||||||
:key="'noteNotif' + notificationGroup.value.id"
|
|
||||||
:note="notificationGroup.value.note"
|
|
||||||
:collapsedReply="
|
|
||||||
!!notificationGroup.value.note.parent_note
|
|
||||||
"
|
"
|
||||||
|
:key="'noteNotif' + notification.id"
|
||||||
|
:note="notification.note"
|
||||||
|
:collapsedReply="!!notification.note.parent_note"
|
||||||
/>
|
/>
|
||||||
<XNotification
|
<XNotification
|
||||||
v-else
|
v-else
|
||||||
:key="'basicNotif' + notificationGroup.value.id"
|
:key="'basicNotif' + notification.id"
|
||||||
:notification="notificationGroup.value"
|
:notification="notification"
|
||||||
:with-time="true"
|
:with-time="true"
|
||||||
:full="true"
|
:full="true"
|
||||||
class="_panel notification"
|
class="_panel notification"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
<XNotificationGroup
|
|
||||||
v-else-if="notificationGroup.type === 'group'"
|
|
||||||
:notificationGroup="notificationGroup"
|
|
||||||
:with-time="true"
|
|
||||||
:full="true"
|
|
||||||
/>
|
|
||||||
</XList>
|
</XList>
|
||||||
<div v-show="next" key="_more_" class="cxiknjgy _gap">
|
</template>
|
||||||
<MkButton
|
</MagPagination>
|
||||||
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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||||
computed,
|
import type { ComponentExposed } from "vue-component-type-helpers";
|
||||||
onActivated,
|
|
||||||
onDeactivated,
|
|
||||||
onMounted,
|
|
||||||
onUnmounted,
|
|
||||||
ref,
|
|
||||||
watch,
|
|
||||||
} from "vue";
|
|
||||||
import XNotification from "@/components/MagNotification.vue";
|
import XNotification from "@/components/MagNotification.vue";
|
||||||
import XNotificationGroup from "@/components/MagNotificationGroup.vue";
|
|
||||||
import XList from "@/components/MkDateSeparatedList.vue";
|
import XList from "@/components/MkDateSeparatedList.vue";
|
||||||
import XNote from "@/components/MagNote.vue";
|
import XNote from "@/components/MagNote.vue";
|
||||||
import { magStream, stream } from "@/stream";
|
import { magStream, stream } from "@/stream";
|
||||||
import { $i } from "@/account";
|
import { $i } from "@/account";
|
||||||
|
import MagPagination, { Paging } from "@/components/MagPagination.vue";
|
||||||
|
import { endpoints, packed, types } from "magnetar-common";
|
||||||
import { i18n } from "@/i18n";
|
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 { 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<{
|
const props = defineProps<{
|
||||||
includeTypes?: types.NotificationType[];
|
includeTypes?: types.NotificationType[];
|
||||||
unreadOnly?: boolean;
|
unreadOnly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
type NotificationGroup = (
|
const pagingComponent =
|
||||||
| {
|
ref<
|
||||||
type: "group";
|
ComponentExposed<
|
||||||
value: packed.PackNotification[];
|
typeof MagPagination<typeof endpoints.GetNotifications>
|
||||||
users: packed.PackUserBase[];
|
>
|
||||||
}
|
>();
|
||||||
| { type: "single"; value: packed.PackNotification }
|
|
||||||
) & { id: string; created_at: string };
|
|
||||||
|
|
||||||
const rootEl = ref<HTMLElement>();
|
const pagination: Paging<typeof endpoints.GetNotifications> = reactive({
|
||||||
const items = ref<NotificationGroup[]>([]);
|
endpoint: endpoints.GetNotifications,
|
||||||
const queue = ref<packed.PackNotification[]>([]);
|
pathParams: {},
|
||||||
const initialFetching = ref(true);
|
params: {
|
||||||
const moreFetching = ref(false);
|
include_types: props.includeTypes ?? undefined,
|
||||||
const next = ref<URL | null>(null);
|
exclude_types: props.includeTypes
|
||||||
const isBackTop = ref(false);
|
? undefined
|
||||||
const empty = computed(() => items.value.length === 0);
|
: $i?.mutingNotificationTypes,
|
||||||
const error = ref(false);
|
unread_only: props.unreadOnly,
|
||||||
const displayLimit = 50;
|
} as types.NotificationsReq,
|
||||||
const groupSizeLimit = 12;
|
});
|
||||||
const allowedGroups: types.NotificationType[] = ["Reaction", "Renote"];
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.includeTypes,
|
() => props.includeTypes,
|
||||||
() => reload()
|
(types) => {
|
||||||
|
pagination.params!.include_types = types ?? undefined;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const onNotification = (notification: packed.PackNotification) => {
|
const onNotification = (notification: packed.PackNotification) => {
|
||||||
|
@ -144,53 +97,49 @@ const onNotification = (notification: packed.PackNotification) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMuted) {
|
if (!isMuted) {
|
||||||
prepend({
|
pagingComponent.value?.prepend({
|
||||||
...notification,
|
...notification,
|
||||||
is_read: document.visibilityState === "visible",
|
isRead: document.visibilityState === "visible",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let notifStream: ((e: ChannelEvent) => void) | undefined;
|
let notifStream: ((e: ChannelEvent) => void) | undefined;
|
||||||
let connection: Connection<Channels["main"]>;
|
let connection;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
notifStream = magStream.useFiltered("Notification", onNotification);
|
notifStream = magStream.useFiltered("Notification", onNotification);
|
||||||
|
|
||||||
connection = stream.useChannel("main");
|
connection = stream.useChannel("main");
|
||||||
connection.on("readAllNotifications", () => {
|
connection.on("readAllNotifications", () => {
|
||||||
for (const item of queue.value) {
|
if (pagingComponent.value) {
|
||||||
|
for (const item of pagingComponent.value.queue) {
|
||||||
item.is_read = true;
|
item.is_read = true;
|
||||||
}
|
}
|
||||||
|
for (const item of pagingComponent.value.items) {
|
||||||
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;
|
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;
|
|
||||||
});
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -200,358 +149,11 @@ onUnmounted(() => {
|
||||||
if (connection) connection.dispose();
|
if (connection) connection.dispose();
|
||||||
if (notifStream) magStream.off("message", notifStream);
|
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 = [
|
|
||||||
{
|
|
||||||
...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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.elsfgstc {
|
.elsfgstc {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: var(--radius);
|
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>
|
</style>
|
||||||
|
|
|
@ -54,7 +54,6 @@ import { $i } from "@/account";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import { definePageMetadata } from "@/scripts/page-metadata";
|
import { definePageMetadata } from "@/scripts/page-metadata";
|
||||||
import MkPushNotificationAllowButton from "@/components/MkPushNotificationAllowButton.vue";
|
import MkPushNotificationAllowButton from "@/components/MkPushNotificationAllowButton.vue";
|
||||||
import MagNotificationSettingsWindow from "@/components/MagNotificationSettingsWindow.vue";
|
|
||||||
import {
|
import {
|
||||||
magLegacyNotificationType,
|
magLegacyNotificationType,
|
||||||
magNotificationType,
|
magNotificationType,
|
||||||
|
@ -90,9 +89,9 @@ function configure() {
|
||||||
)
|
)
|
||||||
.includes(x)
|
.includes(x)
|
||||||
);
|
);
|
||||||
os.popup<typeof MagNotificationSettingsWindow>(
|
os.popup(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent(
|
||||||
() => import("@/components/MagNotificationSettingsWindow.vue")
|
() => import("@/components/MkNotificationSettingWindow.vue")
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
includingTypes,
|
includingTypes,
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { updateColumn } from "./deck-store";
|
||||||
import XNotifications from "@/components/MagNotifications.vue";
|
import XNotifications from "@/components/MagNotifications.vue";
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import MagNotificationSettingsWindow from "@/components/MagNotificationSettingsWindow.vue";
|
import MkNotificationSettingWindow from "@/components/MkNotificationSettingWindow.vue";
|
||||||
import { types } from "magnetar-common";
|
import { types } from "magnetar-common";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -39,9 +39,9 @@ const includingTypes = ref<types.NotificationType[] | undefined>(
|
||||||
);
|
);
|
||||||
|
|
||||||
function func(): void {
|
function func(): void {
|
||||||
os.popup<typeof MagNotificationSettingsWindow>(
|
os.popup<typeof MkNotificationSettingWindow>(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent(
|
||||||
() => import("@/components/MagNotificationSettingsWindow.vue")
|
() => import("@/components/MkNotificationSettingWindow.vue")
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
includingTypes: props.column.includingTypes,
|
includingTypes: props.column.includingTypes,
|
||||||
|
|
|
@ -32,10 +32,10 @@ import { useWidgetPropsManager, Widget, WidgetComponentExpose } from "./widget";
|
||||||
import { GetFormResultType } from "@/scripts/form";
|
import { GetFormResultType } from "@/scripts/form";
|
||||||
import MkContainer from "@/components/MkContainer.vue";
|
import MkContainer from "@/components/MkContainer.vue";
|
||||||
import XNotifications from "@/components/MagNotifications.vue";
|
import XNotifications from "@/components/MagNotifications.vue";
|
||||||
|
import MkNotificationSettingWindow from "@/components/MkNotificationSettingWindow.vue";
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import { types } from "magnetar-common";
|
import { types } from "magnetar-common";
|
||||||
import MagNotificationSettingsWindow from "@/components/MagNotificationSettingsWindow.vue";
|
|
||||||
|
|
||||||
const name = "notifications";
|
const name = "notifications";
|
||||||
|
|
||||||
|
@ -72,8 +72,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(
|
||||||
|
|
||||||
const configureNotification = () => {
|
const configureNotification = () => {
|
||||||
os.popup(
|
os.popup(
|
||||||
defineAsyncComponent<typeof MagNotificationSettingsWindow>(
|
defineAsyncComponent<typeof MkNotificationSettingWindow>(
|
||||||
() => import("@/components/MagNotificationSettingsWindow.vue")
|
() => import("@/components/MkNotificationSettingWindow.vue")
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
includingTypes:
|
includingTypes:
|
||||||
|
|
Loading…
Reference in New Issue