summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkStreamingNotificationsTimeline.vue')
-rw-r--r--packages/frontend/src/components/MkStreamingNotificationsTimeline.vue199
1 files changed, 199 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
new file mode 100644
index 0000000000..931f6ae115
--- /dev/null
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -0,0 +1,199 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
+ <MkLoading v-if="paginator.fetching.value"/>
+
+ <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
+
+ <div v-else-if="paginator.items.value.length === 0" key="_empty_">
+ <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotifications"/></slot>
+ </div>
+
+ <div v-else ref="rootEl">
+ <component
+ :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
+ :moveClass="$style.transition_x_move"
+ tag="div"
+ >
+ <div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item">
+ <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date">
+ <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span>
+ <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
+ <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
+ </div>
+ <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/>
+ <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
+ </div>
+ </component>
+ <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
+ <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
+ <MkLoading v-else/>
+ </button>
+ </div>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue';
+import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
+import type { notificationTypes } from '@@/js/const.js';
+import XNotification from '@/components/MkNotification.vue';
+import MkNote from '@/components/MkNote.vue';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
+import { prefer } from '@/preferences.js';
+import { store } from '@/store.js';
+import { usePagination } from '@/use/use-pagination.js';
+import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
+
+const props = defineProps<{
+ excludeTypes?: typeof notificationTypes[number][];
+}>();
+
+const rootEl = useTemplateRef('rootEl');
+
+const paginator = usePagination({
+ ctx: prefer.s.useGroupedNotifications ? {
+ endpoint: 'i/notifications-grouped' as const,
+ limit: 20,
+ params: computed(() => ({
+ excludeTypes: props.excludeTypes ?? undefined,
+ })),
+ } : {
+ endpoint: 'i/notifications' as const,
+ limit: 20,
+ params: computed(() => ({
+ excludeTypes: props.excludeTypes ?? undefined,
+ })),
+ },
+});
+
+const MIN_POLLING_INTERVAL = 1000 * 10;
+const POLLING_INTERVAL =
+ prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
+ prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
+ prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
+ MIN_POLLING_INTERVAL;
+
+if (!store.s.realtimeMode) {
+ useInterval(async () => {
+ paginator.fetchNewer({
+ toQueue: false,
+ });
+ }, POLLING_INTERVAL, {
+ immediate: false,
+ afterMounted: true,
+ });
+}
+
+function onNotification(notification) {
+ const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
+ if (isMuted || window.document.visibilityState === 'visible') {
+ if (store.s.realtimeMode) {
+ useStream().send('readNotification');
+ }
+ }
+
+ if (!isMuted) {
+ paginator.prepend(notification);
+ }
+}
+
+function reload() {
+ return paginator.reload();
+}
+
+let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null;
+
+onMounted(() => {
+ if (store.s.realtimeMode) {
+ connection = useStream().useChannel('main');
+ connection.on('notification', onNotification);
+ connection.on('notificationFlushed', reload);
+ }
+});
+
+onUnmounted(() => {
+ if (connection) connection.dispose();
+});
+
+defineExpose({
+ reload,
+});
+</script>
+
+<style lang="scss" module>
+.transition_x_move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.transition_x_enterActive {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+
+ &.content,
+ .content {
+ /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
+ content-visibility: visible !important;
+ }
+}
+
+.transition_x_leaveActive {
+ transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
+}
+
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateY(max(-64px, -100%));
+}
+
+@supports (interpolate-size: allow-keywords) {
+ .transition_x_enterFrom {
+ interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+ height: 0;
+ }
+}
+
+.transition_x_leaveTo {
+ opacity: 0;
+}
+
+.notifications {
+ container-type: inline-size;
+ background: var(--MI_THEME-panel);
+}
+
+.item {
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.date {
+ display: flex;
+ font-size: 85%;
+ align-items: center;
+ justify-content: center;
+ gap: 1em;
+ opacity: 0.75;
+ padding: 8px 8px;
+ margin: 0 auto;
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.more {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 16px;
+ background: var(--MI_THEME-panel);
+ border-top: solid 0.5px var(--MI_THEME-divider);
+}
+</style>