feat(frontend): スワイプやボタンでタイムラインを再読込する機能 (misskey-dev#12113) (MisskeyIO#206)
* feat(frontend): スワイプやボタンでタイムラインを再読込する機能 (misskey-dev#12113) * tweak MkPullToRefresh cheery-picked from52dbab56a4Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * enhance(frontend): improve pull to refresh cheery-picked fromd0d32e8846Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		| @@ -1098,6 +1098,10 @@ expired: "Expired" | |||||||
| doYouAgree: "Agree?" | doYouAgree: "Agree?" | ||||||
| beSureToReadThisAsItIsImportant: "Please read this important information." | beSureToReadThisAsItIsImportant: "Please read this important information." | ||||||
| iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree." | iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree." | ||||||
|  | releaseToRefresh: "Release to reload" | ||||||
|  | refreshing: "Reloading" | ||||||
|  | pullDownToRefresh: "Pull down to reload" | ||||||
|  | disableStreamingTimeline: "Disable realtime update on timeline" | ||||||
| _initialAccountSetting: | _initialAccountSetting: | ||||||
|   accountCreated: "Your account was successfully created!" |   accountCreated: "Your account was successfully created!" | ||||||
|   letsStartAccountSetup: "For starters, let's set up your profile." |   letsStartAccountSetup: "For starters, let's set up your profile." | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1109,6 +1109,10 @@ export interface Locale { | |||||||
|     "pastAnnouncements": string; |     "pastAnnouncements": string; | ||||||
|     "youHaveUnreadAnnouncements": string; |     "youHaveUnreadAnnouncements": string; | ||||||
|     "externalServices": string; |     "externalServices": string; | ||||||
|  |     "releaseToRefresh": string; | ||||||
|  |     "refreshing": string; | ||||||
|  |     "pullDownToRefresh": string; | ||||||
|  |     "disableStreamingTimeline": string; | ||||||
|     "_announcement": { |     "_announcement": { | ||||||
|         "forExistingUsers": string; |         "forExistingUsers": string; | ||||||
|         "forExistingUsersDescription": string; |         "forExistingUsersDescription": string; | ||||||
|   | |||||||
| @@ -1106,6 +1106,10 @@ currentAnnouncements: "現在のお知らせ" | |||||||
| pastAnnouncements: "過去のお知らせ" | pastAnnouncements: "過去のお知らせ" | ||||||
| youHaveUnreadAnnouncements: "未読のお知らせがあります。" | youHaveUnreadAnnouncements: "未読のお知らせがあります。" | ||||||
| externalServices: "外部サービス" | externalServices: "外部サービス" | ||||||
|  | releaseToRefresh: "離してリロード" | ||||||
|  | refreshing: "リロード中" | ||||||
|  | pullDownToRefresh: "引っ張ってリロード" | ||||||
|  | disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" | ||||||
|  |  | ||||||
| _announcement: | _announcement: | ||||||
|   forExistingUsers: "既存ユーザーのみ" |   forExistingUsers: "既存ユーザーのみ" | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ import { common } from './common'; | |||||||
| import { version, ui, lang, updateLocale } from '@/config'; | import { version, ui, lang, updateLocale } from '@/config'; | ||||||
| import { i18n, updateI18n } from '@/i18n'; | import { i18n, updateI18n } from '@/i18n'; | ||||||
| import { confirm, alert, post, popup, toast } from '@/os'; | import { confirm, alert, post, popup, toast } from '@/os'; | ||||||
| import { useStream } from '@/stream'; | import { useStream, isReloading } from '@/stream'; | ||||||
| import * as sound from '@/scripts/sound'; | import * as sound from '@/scripts/sound'; | ||||||
| import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; | import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; | ||||||
| import { defaultStore, ColdDeviceStorage } from '@/store'; | import { defaultStore, ColdDeviceStorage } from '@/store'; | ||||||
| @@ -39,6 +39,7 @@ export async function mainBoot() { | |||||||
|  |  | ||||||
| 	let reloadDialogShowing = false; | 	let reloadDialogShowing = false; | ||||||
| 	stream.on('_disconnected_', async () => { | 	stream.on('_disconnected_', async () => { | ||||||
|  | 		if (isReloading) return; | ||||||
| 		if (defaultStore.state.serverDisconnectedBehavior === 'reload') { | 		if (defaultStore.state.serverDisconnectedBehavior === 'reload') { | ||||||
| 			location.reload(); | 			location.reload(); | ||||||
| 		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { | 		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { | ||||||
|   | |||||||
| @@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| --> | --> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| <MkPagination ref="pagingComponent" :pagination="pagination"> | <MkPullToRefresh :refresher="() => reload()"> | ||||||
|  | 	<MkPagination ref="pagingComponent" :pagination="pagination"> | ||||||
| 		<template #empty> | 		<template #empty> | ||||||
| 			<div class="_fullinfo"> | 			<div class="_fullinfo"> | ||||||
| 				<img :src="infoImageUrl" class="_ghost"/> | 				<img :src="infoImageUrl" class="_ghost"/> | ||||||
| @@ -18,12 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 				<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/> | 				<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/> | ||||||
| 			</MkDateSeparatedList> | 			</MkDateSeparatedList> | ||||||
| 		</template> | 		</template> | ||||||
| </MkPagination> | 	</MkPagination> | ||||||
|  | </MkPullToRefresh> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { onUnmounted, onMounted, computed, shallowRef } from 'vue'; | import { onUnmounted, onActivated, onMounted, computed, shallowRef } from 'vue'; | ||||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||||
|  | import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; | ||||||
| import XNotification from '@/components/MkNotification.vue'; | import XNotification from '@/components/MkNotification.vue'; | ||||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||||
| import MkNote from '@/components/MkNote.vue'; | import MkNote from '@/components/MkNote.vue'; | ||||||
| @@ -48,16 +51,24 @@ const pagination: Paging = { | |||||||
| 	})), | 	})), | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const onNotification = (notification) => { | function onNotification(notification) { | ||||||
| 	const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); | 	const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); | ||||||
| 	if (isMuted || document.visibilityState === 'visible') { | 	if (isMuted || document.visibilityState === 'visible') { | ||||||
| 		useStream().send('readNotification'); | 		useStream().send('readNotification'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if (!isMuted) { | 	if (!isMuted) { | ||||||
| 		pagingComponent.value.prepend(notification); | 		pagingComponent.value?.prepend(notification); | ||||||
| 	} | 	} | ||||||
| }; | } | ||||||
|  |  | ||||||
|  | function reload() { | ||||||
|  | 	return new Promise<void>((res) => { | ||||||
|  | 		pagingComponent.value?.reload().then(() => { | ||||||
|  | 			res(); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  |  | ||||||
| let connection; | let connection; | ||||||
|  |  | ||||||
| @@ -66,6 +77,12 @@ onMounted(() => { | |||||||
| 	connection.on('notification', onNotification); | 	connection.on('notification', onNotification); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | onActivated(() => { | ||||||
|  | 	pagingComponent.value?.reload(); | ||||||
|  | 	connection = useStream().useChannel('main'); | ||||||
|  | 	connection.on('notification', onNotification); | ||||||
|  | }); | ||||||
|  |  | ||||||
| onUnmounted(() => { | onUnmounted(() => { | ||||||
| 	if (connection) connection.dispose(); | 	if (connection) connection.dispose(); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -166,6 +166,8 @@ defineExpose({ | |||||||
|  |  | ||||||
| <style lang="scss" module> | <style lang="scss" module> | ||||||
| .root { | .root { | ||||||
|  | 	overscroll-behavior: none; | ||||||
|  |  | ||||||
| 	min-height: 100%; | 	min-height: 100%; | ||||||
| 	background: var(--bg); | 	background: var(--bg); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -90,6 +90,7 @@ const props = withDefaults(defineProps<{ | |||||||
|  |  | ||||||
| const emit = defineEmits<{ | const emit = defineEmits<{ | ||||||
| 	(ev: 'queue', count: number): void; | 	(ev: 'queue', count: number): void; | ||||||
|  | 	(ev: 'status', error: boolean): void; | ||||||
| }>(); | }>(); | ||||||
|  |  | ||||||
| let rootEl = $shallowRef<HTMLElement>(); | let rootEl = $shallowRef<HTMLElement>(); | ||||||
| @@ -164,6 +165,11 @@ watch(queue, (a, b) => { | |||||||
| 	emit('queue', queue.value.length); | 	emit('queue', queue.value.length); | ||||||
| }, { deep: true }); | }, { deep: true }); | ||||||
|  |  | ||||||
|  | watch(error, (n, o) => { | ||||||
|  | 	if (n === o) return; | ||||||
|  | 	emit('status', n); | ||||||
|  | }); | ||||||
|  |  | ||||||
| async function init(): Promise<void> { | async function init(): Promise<void> { | ||||||
| 	queue.value = []; | 	queue.value = []; | ||||||
| 	fetching.value = true; | 	fetching.value = true; | ||||||
|   | |||||||
							
								
								
									
										248
									
								
								packages/frontend/src/components/MkPullToRefresh.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								packages/frontend/src/components/MkPullToRefresh.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,248 @@ | |||||||
|  | <!-- | ||||||
|  | SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||||
|  | SPDX-License-Identifier: AGPL-3.0-only | ||||||
|  | --> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  | 	<div ref="rootEl"> | ||||||
|  | 		<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`"> | ||||||
|  | 			<div :class="$style.frameContent"> | ||||||
|  | 				<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/> | ||||||
|  | 				<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i> | ||||||
|  | 				<div :class="$style.text"> | ||||||
|  | 					<template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template> | ||||||
|  | 					<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template> | ||||||
|  | 					<template v-else>{{ i18n.ts.pullDownToRefresh }}</template> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 		<div :class="{ [$style.slotClip]: isPullStart }"> | ||||||
|  | 			<slot/> | ||||||
|  | 		</div> | ||||||
|  | 	</div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import MkLoading from '@/components/global/MkLoading.vue'; | ||||||
|  | import { onMounted, onUnmounted } from 'vue'; | ||||||
|  | import { i18n } from '@/i18n.js'; | ||||||
|  |  | ||||||
|  | const SCROLL_STOP = 10; | ||||||
|  | const MAX_PULL_DISTANCE = Infinity; | ||||||
|  | const FIRE_THRESHOLD = 230; | ||||||
|  | const RELEASE_TRANSITION_DURATION = 200; | ||||||
|  | const PULL_BRAKE_BASE = 2; | ||||||
|  | const PULL_BRAKE_FACTOR = 200; | ||||||
|  |  | ||||||
|  | let isPullStart = $ref(false); | ||||||
|  | let isPullEnd = $ref(false); | ||||||
|  | let isRefreshing = $ref(false); | ||||||
|  | let pullDistance = $ref(0); | ||||||
|  |  | ||||||
|  | let supportPointerDesktop = false; | ||||||
|  | let startScreenY: number | null = null; | ||||||
|  |  | ||||||
|  | const rootEl = $shallowRef<HTMLDivElement>(); | ||||||
|  | let scrollEl: HTMLElement | null = null; | ||||||
|  |  | ||||||
|  | let disabled = false; | ||||||
|  |  | ||||||
|  | const props = withDefaults(defineProps<{ | ||||||
|  | 	refresher: () => Promise<void>; | ||||||
|  | }>(), { | ||||||
|  | 	refresher: () => Promise.resolve(), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emits = defineEmits<(ev: "refresh") => void>(); | ||||||
|  |  | ||||||
|  | function getScrollableParentElement(node) { | ||||||
|  | 	if (node == null) { | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if (node.scrollHeight > node.clientHeight) { | ||||||
|  | 		return node; | ||||||
|  | 	} else { | ||||||
|  | 		return getScrollableParentElement(node.parentNode); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getScreenY(event) { | ||||||
|  | 	if (supportPointerDesktop) { | ||||||
|  | 		return event.screenY; | ||||||
|  | 	} | ||||||
|  | 	return event.touches[0].screenY; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function moveStart(event) { | ||||||
|  | 	if (!isPullStart && !isRefreshing && !disabled) { | ||||||
|  | 		isPullStart = true; | ||||||
|  | 		startScreenY = getScreenY(event); | ||||||
|  | 		pullDistance = 0; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function moveBySystem(to: number): Promise<void> { | ||||||
|  | 	return new Promise(r => { | ||||||
|  | 		const startHeight = pullDistance; | ||||||
|  | 		const overHeight = pullDistance - to; | ||||||
|  | 		if (overHeight < 1) { | ||||||
|  | 			r(); | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 		const startTime = Date.now(); | ||||||
|  | 		let intervalId = setInterval(() => { | ||||||
|  | 			const time = Date.now() - startTime; | ||||||
|  | 			if (time > RELEASE_TRANSITION_DURATION) { | ||||||
|  | 				pullDistance = to; | ||||||
|  | 				clearInterval(intervalId); | ||||||
|  | 				r(); | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 			const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time; | ||||||
|  | 			if (pullDistance < nextHeight) return; | ||||||
|  | 			pullDistance = nextHeight; | ||||||
|  | 		}, 1); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function fixOverContent() { | ||||||
|  | 	if (pullDistance > FIRE_THRESHOLD) { | ||||||
|  | 		await moveBySystem(FIRE_THRESHOLD); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function closeContent() { | ||||||
|  | 	if (pullDistance > 0) { | ||||||
|  | 		await moveBySystem(0); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function moveEnd() { | ||||||
|  | 	if (isPullStart && !isRefreshing) { | ||||||
|  | 		startScreenY = null; | ||||||
|  | 		if (isPullEnd) { | ||||||
|  | 			isPullEnd = false; | ||||||
|  | 			isRefreshing = true; | ||||||
|  | 			fixOverContent().then(() => { | ||||||
|  | 				emits('refresh'); | ||||||
|  | 				props.refresher().then(() => { | ||||||
|  | 					refreshFinished(); | ||||||
|  | 				}); | ||||||
|  | 			}); | ||||||
|  | 		} else { | ||||||
|  | 			closeContent().then(() => isPullStart = false); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function moving(event) { | ||||||
|  | 	if (!isPullStart || isRefreshing || disabled) return; | ||||||
|  |  | ||||||
|  | 	if (!scrollEl) { | ||||||
|  | 		scrollEl = getScrollableParentElement(rootEl); | ||||||
|  | 	} | ||||||
|  | 	if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) { | ||||||
|  | 		pullDistance = 0; | ||||||
|  | 		isPullEnd = false; | ||||||
|  | 		moveEnd(); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if (startScreenY === null) { | ||||||
|  | 		startScreenY = getScreenY(event); | ||||||
|  | 	} | ||||||
|  | 	const moveScreenY = getScreenY(event); | ||||||
|  |  | ||||||
|  | 	const moveHeight = moveScreenY - startScreenY!; | ||||||
|  | 	pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); | ||||||
|  |  | ||||||
|  | 	isPullEnd = pullDistance >= FIRE_THRESHOLD; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * emit(refresh)が完了したことを知らせる関数 | ||||||
|  |  * | ||||||
|  |  * タイムアウトがないのでこれを最終的に実行しないと出たままになる | ||||||
|  |  */ | ||||||
|  | function refreshFinished() { | ||||||
|  | 	closeContent().then(() => { | ||||||
|  | 		isPullStart = false; | ||||||
|  | 		isRefreshing = false; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function setDisabled(value) { | ||||||
|  | 	disabled = value; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  | 	// マウス操作でpull to refreshするのは不便そう | ||||||
|  | 	//supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop'; | ||||||
|  |  | ||||||
|  | 	if (supportPointerDesktop) { | ||||||
|  | 		rootEl.addEventListener('pointerdown', moveStart); | ||||||
|  | 		// ポインターの場合、ポップアップ系の動作をするとdownだけ発火されてupが発火されないため | ||||||
|  | 		window.addEventListener('pointerup', moveEnd); | ||||||
|  | 		rootEl.addEventListener('pointermove', moving, { passive: true }); | ||||||
|  | 	} else { | ||||||
|  | 		rootEl.addEventListener('touchstart', moveStart); | ||||||
|  | 		rootEl.addEventListener('touchend', moveEnd); | ||||||
|  | 		rootEl.addEventListener('touchmove', moving, { passive: true }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | onUnmounted(() => { | ||||||
|  | 	if (supportPointerDesktop) window.removeEventListener('pointerup', moveEnd); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | defineExpose({ | ||||||
|  | 	setDisabled, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss" module> | ||||||
|  | .frame { | ||||||
|  | 	position: relative; | ||||||
|  | 	overflow: clip; | ||||||
|  |  | ||||||
|  | 	width: 100%; | ||||||
|  | 	min-height: var(--frame-min-height, 0px); | ||||||
|  |  | ||||||
|  | 	mask-image: linear-gradient(90deg, #000 0%, #000 80%, transparent); | ||||||
|  | 	-webkit-mask-image: -webkit-linear-gradient(90deg, #000 0%, #000 80%, transparent); | ||||||
|  |  | ||||||
|  | 	pointer-events: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .frameContent { | ||||||
|  | 	position: absolute; | ||||||
|  | 	bottom: 0; | ||||||
|  | 	width: 100%; | ||||||
|  | 	margin: 5px 0; | ||||||
|  | 	display: flex; | ||||||
|  | 	flex-direction: column; | ||||||
|  | 	align-items: center; | ||||||
|  | 	font-size: 14px; | ||||||
|  |  | ||||||
|  | 	> .icon, > .loader { | ||||||
|  | 		margin: 6px 0; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	> .icon { | ||||||
|  | 		transition: transform .25s; | ||||||
|  |  | ||||||
|  | 		&.refresh { | ||||||
|  | 			transform: rotate(180deg); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	> .text { | ||||||
|  | 		margin: 5px 0; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .slotClip { | ||||||
|  | 	overflow-y: clip; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -4,13 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| --> | --> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| <MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> | <MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()"> | ||||||
|  | 	<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)" @status="prComponent.setDisabled($event)"/> | ||||||
|  | </MkPullToRefresh> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { computed, provide, onUnmounted } from 'vue'; | import { computed, provide, onUnmounted } from 'vue'; | ||||||
| import MkNotes from '@/components/MkNotes.vue'; | import MkNotes from '@/components/MkNotes.vue'; | ||||||
| import { useStream } from '@/stream'; | import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; | ||||||
|  | import { useStream, reloadStream } from '@/stream'; | ||||||
| import * as sound from '@/scripts/sound'; | import * as sound from '@/scripts/sound'; | ||||||
| import { $i } from '@/account'; | import { $i } from '@/account'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| @@ -31,6 +34,7 @@ const emit = defineEmits<{ | |||||||
|  |  | ||||||
| provide('inChannel', computed(() => props.src === 'channel')); | provide('inChannel', computed(() => props.src === 'channel')); | ||||||
|  |  | ||||||
|  | const prComponent: InstanceType<typeof MkPullToRefresh> = $ref(); | ||||||
| const tlComponent: InstanceType<typeof MkNotes> = $ref(); | const tlComponent: InstanceType<typeof MkNotes> = $ref(); | ||||||
|  |  | ||||||
| const prepend = note => { | const prepend = note => { | ||||||
| @@ -49,74 +53,44 @@ let connection; | |||||||
| let connection2; | let connection2; | ||||||
|  |  | ||||||
| const stream = useStream(); | const stream = useStream(); | ||||||
|  | const connectChannel = () => { | ||||||
| if (props.src === 'antenna') { | 	if (props.src === 'antenna') { | ||||||
| 	endpoint = 'antennas/notes'; |  | ||||||
| 	query = { |  | ||||||
| 		antennaId: props.antenna, |  | ||||||
| 	}; |  | ||||||
| 		connection = stream.useChannel('antenna', { | 		connection = stream.useChannel('antenna', { | ||||||
| 			antennaId: props.antenna, | 			antennaId: props.antenna, | ||||||
| 		}); | 		}); | ||||||
| 		connection.on('note', prepend); | 		connection.on('note', prepend); | ||||||
| } else if (props.src === 'home') { | 	} else if (props.src === 'home') { | ||||||
| 	endpoint = 'notes/timeline'; |  | ||||||
| 	query = { |  | ||||||
| 		withReplies: defaultStore.state.showTimelineReplies, |  | ||||||
| 	}; |  | ||||||
| 		connection = stream.useChannel('homeTimeline', { | 		connection = stream.useChannel('homeTimeline', { | ||||||
| 			withReplies: defaultStore.state.showTimelineReplies, | 			withReplies: defaultStore.state.showTimelineReplies, | ||||||
| 		}); | 		}); | ||||||
| 		connection.on('note', prepend); | 		connection.on('note', prepend); | ||||||
|  |  | ||||||
| 		connection2 = stream.useChannel('main'); | 		connection2 = stream.useChannel('main'); | ||||||
| } else if (props.src === 'local') { | 	} else if (props.src === 'local') { | ||||||
| 	endpoint = 'notes/local-timeline'; |  | ||||||
| 	query = { |  | ||||||
| 		withReplies: defaultStore.state.showTimelineReplies, |  | ||||||
| 	}; |  | ||||||
| 		connection = stream.useChannel('localTimeline', { | 		connection = stream.useChannel('localTimeline', { | ||||||
| 			withReplies: defaultStore.state.showTimelineReplies, | 			withReplies: defaultStore.state.showTimelineReplies, | ||||||
| 		}); | 		}); | ||||||
| 		connection.on('note', prepend); | 		connection.on('note', prepend); | ||||||
| } else if (props.src === 'media') { | 	} else if (props.src === 'media') { | ||||||
| 	endpoint = 'notes/hybrid-timeline'; |  | ||||||
| 	query = { |  | ||||||
| 		withFiles: true, |  | ||||||
| 		withReplies: defaultStore.state.showTimelineReplies, |  | ||||||
| 	}; |  | ||||||
| 		connection = stream.useChannel('hybridTimeline', { | 		connection = stream.useChannel('hybridTimeline', { | ||||||
| 			withFiles: true, | 			withFiles: true, | ||||||
| 			withReplies: defaultStore.state.showTimelineReplies, | 			withReplies: defaultStore.state.showTimelineReplies, | ||||||
| 		}); | 		}); | ||||||
| 		connection.on('note', prepend); | 		connection.on('note', prepend); | ||||||
| } else if (props.src === 'social') { | 	} else if (props.src === 'social') { | ||||||
| 	endpoint = 'notes/hybrid-timeline'; |  | ||||||
| 	query = { |  | ||||||
| 		withReplies: defaultStore.state.showTimelineReplies, |  | ||||||
| 	}; |  | ||||||
| 		connection = stream.useChannel('hybridTimeline', { | 		connection = stream.useChannel('hybridTimeline', { | ||||||
| 			withReplies: defaultStore.state.showTimelineReplies, | 			withReplies: defaultStore.state.showTimelineReplies, | ||||||
| 		}); | 		}); | ||||||
| 		connection.on('note', prepend); | 		connection.on('note', prepend); | ||||||
| } else if (props.src === 'global') { | 	} else if (props.src === 'global') { | ||||||
| 	endpoint = 'notes/global-timeline'; |  | ||||||
| 	query = { |  | ||||||
| 		withReplies: defaultStore.state.showTimelineReplies, |  | ||||||
| 	}; |  | ||||||
| 		connection = stream.useChannel('globalTimeline', { | 		connection = stream.useChannel('globalTimeline', { | ||||||
| 			withReplies: defaultStore.state.showTimelineReplies, | 			withReplies: defaultStore.state.showTimelineReplies, | ||||||
| 		}); | 		}); | ||||||
| 		connection.on('note', prepend); | 		connection.on('note', prepend); | ||||||
| } else if (props.src === 'mentions') { | 	} else if (props.src === 'mentions') { | ||||||
| 	endpoint = 'notes/mentions'; |  | ||||||
| 		connection = stream.useChannel('main'); | 		connection = stream.useChannel('main'); | ||||||
| 		connection.on('mention', prepend); | 		connection.on('mention', prepend); | ||||||
| } else if (props.src === 'directs') { | 	} else if (props.src === 'directs') { | ||||||
| 	endpoint = 'notes/mentions'; |  | ||||||
| 	query = { |  | ||||||
| 		visibility: 'specified', |  | ||||||
| 	}; |  | ||||||
| 		const onNote = note => { | 		const onNote = note => { | ||||||
| 			if (note.visibility === 'specified') { | 			if (note.visibility === 'specified') { | ||||||
| 				prepend(note); | 				prepend(note); | ||||||
| @@ -124,33 +98,86 @@ if (props.src === 'antenna') { | |||||||
| 		}; | 		}; | ||||||
| 		connection = stream.useChannel('main'); | 		connection = stream.useChannel('main'); | ||||||
| 		connection.on('mention', onNote); | 		connection.on('mention', onNote); | ||||||
|  | 	} else if (props.src === 'list') { | ||||||
|  | 		connection = stream.useChannel('userList', { | ||||||
|  | 			listId: props.list, | ||||||
|  | 		}); | ||||||
|  | 		connection.on('note', prepend); | ||||||
|  | 	} else if (props.src === 'channel') { | ||||||
|  | 		connection = stream.useChannel('channel', { | ||||||
|  | 			channelId: props.channel, | ||||||
|  | 		}); | ||||||
|  | 		connection.on('note', prepend); | ||||||
|  | 	} else if (props.src === 'role') { | ||||||
|  | 		connection = stream.useChannel('roleTimeline', { | ||||||
|  | 			roleId: props.role, | ||||||
|  | 		}); | ||||||
|  | 		connection.on('note', prepend); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | if (props.src === 'antenna') { | ||||||
|  | 	endpoint = 'antennas/notes'; | ||||||
|  | 	query = { | ||||||
|  | 		antennaId: props.antenna, | ||||||
|  | 	}; | ||||||
|  | } else if (props.src === 'home') { | ||||||
|  | 	endpoint = 'notes/timeline'; | ||||||
|  | 	query = { | ||||||
|  | 		withReplies: defaultStore.state.showTimelineReplies, | ||||||
|  | 	}; | ||||||
|  | } else if (props.src === 'local') { | ||||||
|  | 	endpoint = 'notes/local-timeline'; | ||||||
|  | 	query = { | ||||||
|  | 		withReplies: defaultStore.state.showTimelineReplies, | ||||||
|  | 	}; | ||||||
|  | } else if (props.src === 'media') { | ||||||
|  | 	endpoint = 'notes/hybrid-timeline'; | ||||||
|  | 	query = { | ||||||
|  | 		withFiles: true, | ||||||
|  | 		withReplies: defaultStore.state.showTimelineReplies, | ||||||
|  | 	}; | ||||||
|  | } else if (props.src === 'social') { | ||||||
|  | 	endpoint = 'notes/hybrid-timeline'; | ||||||
|  | 	query = { | ||||||
|  | 		withReplies: defaultStore.state.showTimelineReplies, | ||||||
|  | 	}; | ||||||
|  | } else if (props.src === 'global') { | ||||||
|  | 	endpoint = 'notes/global-timeline'; | ||||||
|  | 	query = { | ||||||
|  | 		withReplies: defaultStore.state.showTimelineReplies, | ||||||
|  | 	}; | ||||||
|  | } else if (props.src === 'mentions') { | ||||||
|  | 	endpoint = 'notes/mentions'; | ||||||
|  | } else if (props.src === 'directs') { | ||||||
|  | 	endpoint = 'notes/mentions'; | ||||||
|  | 	query = { | ||||||
|  | 		visibility: 'specified', | ||||||
|  | 	}; | ||||||
| } else if (props.src === 'list') { | } else if (props.src === 'list') { | ||||||
| 	endpoint = 'notes/user-list-timeline'; | 	endpoint = 'notes/user-list-timeline'; | ||||||
| 	query = { | 	query = { | ||||||
| 		listId: props.list, | 		listId: props.list, | ||||||
| 	}; | 	}; | ||||||
| 	connection = stream.useChannel('userList', { |  | ||||||
| 		listId: props.list, |  | ||||||
| 	}); |  | ||||||
| 	connection.on('note', prepend); |  | ||||||
| } else if (props.src === 'channel') { | } else if (props.src === 'channel') { | ||||||
| 	endpoint = 'channels/timeline'; | 	endpoint = 'channels/timeline'; | ||||||
| 	query = { | 	query = { | ||||||
| 		channelId: props.channel, | 		channelId: props.channel, | ||||||
| 	}; | 	}; | ||||||
| 	connection = stream.useChannel('channel', { |  | ||||||
| 		channelId: props.channel, |  | ||||||
| 	}); |  | ||||||
| 	connection.on('note', prepend); |  | ||||||
| } else if (props.src === 'role') { | } else if (props.src === 'role') { | ||||||
| 	endpoint = 'roles/notes'; | 	endpoint = 'roles/notes'; | ||||||
| 	query = { | 	query = { | ||||||
| 		roleId: props.role, | 		roleId: props.role, | ||||||
| 	}; | 	}; | ||||||
| 	connection = stream.useChannel('roleTimeline', { | } | ||||||
| 		roleId: props.role, |  | ||||||
|  | if (!defaultStore.state.disableStreamingTimeline) { | ||||||
|  | 	connectChannel(); | ||||||
|  |  | ||||||
|  | 	onUnmounted(() => { | ||||||
|  | 		connection.dispose(); | ||||||
|  | 		if (connection2) connection2.dispose(); | ||||||
| 	}); | 	}); | ||||||
| 	connection.on('note', prepend); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| const pagination = { | const pagination = { | ||||||
| @@ -159,15 +186,16 @@ const pagination = { | |||||||
| 	params: query, | 	params: query, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| onUnmounted(() => { | function reloadTimeline() { | ||||||
| 	connection.dispose(); | 	return new Promise<void>((res) => { | ||||||
| 	if (connection2) connection2.dispose(); | 		tlComponent.pagingComponent?.reload().then(() => { | ||||||
| }); | 			reloadStream(); | ||||||
|  | 			res(); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  |  | ||||||
| /* TODO | defineExpose({ | ||||||
| const timetravel = (date?: Date) => { | 	reloadTimeline, | ||||||
| 	this.date = date; | }); | ||||||
| 	this.$refs.tl.reload(); |  | ||||||
| }; |  | ||||||
| */ |  | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -135,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 			<div class="_gaps_s"> | 			<div class="_gaps_s"> | ||||||
| 				<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> | 				<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> | ||||||
| 				<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> | 				<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> | ||||||
|  | 				<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> | ||||||
| 			</div> | 			</div> | ||||||
| 			<MkSelect v-model="serverDisconnectedBehavior"> | 			<MkSelect v-model="serverDisconnectedBehavior"> | ||||||
| 				<template #label>{{ i18n.ts.whenServerDisconnected }}</template> | 				<template #label>{{ i18n.ts.whenServerDisconnected }}</template> | ||||||
| @@ -231,6 +232,7 @@ const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter(' | |||||||
| const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); | const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); | ||||||
| const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); | const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); | ||||||
| const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies')); | const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies')); | ||||||
|  | const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); | ||||||
|  |  | ||||||
| watch(lang, () => { | watch(lang, () => { | ||||||
| 	miLocalStorage.setItem('lang', lang.value as string); | 	miLocalStorage.setItem('lang', lang.value as string); | ||||||
| @@ -264,6 +266,7 @@ watch([ | |||||||
| 	instanceTicker, | 	instanceTicker, | ||||||
| 	overridedDeviceKind, | 	overridedDeviceKind, | ||||||
| 	mediaListWithOneImageAppearance, | 	mediaListWithOneImageAppearance, | ||||||
|  | 	disableStreamingTimeline, | ||||||
| ], async () => { | ], async () => { | ||||||
| 	await reloadAsk(); | 	await reloadAsk(); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ import { i18n } from '@/i18n'; | |||||||
| import { instance } from '@/instance'; | import { instance } from '@/instance'; | ||||||
| import { $i } from '@/account'; | import { $i } from '@/account'; | ||||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
|  | import { deviceKind } from '@/scripts/device-kind.js'; | ||||||
|  |  | ||||||
| provide('shouldOmitHeaderTitle', true); | provide('shouldOmitHeaderTitle', true); | ||||||
|  |  | ||||||
| @@ -121,7 +122,13 @@ function focus(): void { | |||||||
| 	tlComponent.focus(); | 	tlComponent.focus(); | ||||||
| } | } | ||||||
|  |  | ||||||
| const headerActions = $computed(() => []); | const headerActions = $computed(() => [ | ||||||
|  | 	...[deviceKind === 'desktop' ? { | ||||||
|  | 		icon: 'ti ti-refresh', | ||||||
|  | 		text: i18n.ts.reload, | ||||||
|  | 		handler: () => { tlComponent.reloadTimeline(); }, | ||||||
|  | 	} : {}], | ||||||
|  | ]); | ||||||
|  |  | ||||||
| const headerTabs = $computed(() => [{ | const headerTabs = $computed(() => [{ | ||||||
| 	key: 'home', | 	key: 'home', | ||||||
|   | |||||||
| @@ -351,6 +351,10 @@ export const defaultStore = markRaw(new Storage('base', { | |||||||
| 		where: 'device', | 		where: 'device', | ||||||
| 		default: {} as Record<string, Record<string, string[]>>, | 		default: {} as Record<string, Record<string, string[]>>, | ||||||
| 	}, | 	}, | ||||||
|  | 	disableStreamingTimeline: { | ||||||
|  | 		where: 'device', | ||||||
|  | 		default: false, | ||||||
|  | 	}, | ||||||
| })); | })); | ||||||
|  |  | ||||||
| // TODO: 他のタブと永続化されたstateを同期 | // TODO: 他のタブと永続化されたstateを同期 | ||||||
|   | |||||||
| @@ -9,6 +9,9 @@ import { $i } from '@/account'; | |||||||
| import { url } from '@/config'; | import { url } from '@/config'; | ||||||
|  |  | ||||||
| let stream: Misskey.Stream | null = null; | let stream: Misskey.Stream | null = null; | ||||||
|  | let timeoutHeartBeat: number | null = null; | ||||||
|  |  | ||||||
|  | export let isReloading: boolean = false; | ||||||
|  |  | ||||||
| export function useStream(): Misskey.Stream { | export function useStream(): Misskey.Stream { | ||||||
| 	if (stream) return stream; | 	if (stream) return stream; | ||||||
| @@ -17,7 +20,20 @@ export function useStream(): Misskey.Stream { | |||||||
| 		token: $i.token, | 		token: $i.token, | ||||||
| 	} : null)); | 	} : null)); | ||||||
|  |  | ||||||
| 	window.setTimeout(heartbeat, 1000 * 60); | 	timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60); | ||||||
|  |  | ||||||
|  | 	return stream; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function reloadStream() { | ||||||
|  | 	if (!stream) return useStream(); | ||||||
|  | 	if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat); | ||||||
|  | 	isReloading = true; | ||||||
|  |  | ||||||
|  | 	stream.close(); | ||||||
|  | 	stream.once('_connected_', () => isReloading = false); | ||||||
|  | 	stream.stream.reconnect(); | ||||||
|  | 	timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60); | ||||||
|  |  | ||||||
| 	return stream; | 	return stream; | ||||||
| } | } | ||||||
| @@ -26,5 +42,5 @@ function heartbeat(): void { | |||||||
| 	if (stream != null && document.visibilityState === 'visible') { | 	if (stream != null && document.visibilityState === 'visible') { | ||||||
| 		stream.heartbeat(); | 		stream.heartbeat(); | ||||||
| 	} | 	} | ||||||
| 	window.setTimeout(heartbeat, 1000 * 60); | 	timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
|  |  | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { onUnmounted } from 'vue'; | import { onUnmounted } from 'vue'; | ||||||
| import { useStream } from '@/stream'; | import { useStream, isReloading } from '@/stream'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import MkButton from '@/components/MkButton.vue'; | import MkButton from '@/components/MkButton.vue'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| @@ -27,6 +27,8 @@ let hasDisconnected = $ref(false); | |||||||
| let timeoutId = $ref<number>(); | let timeoutId = $ref<number>(); | ||||||
|  |  | ||||||
| function onDisconnected() { | function onDisconnected() { | ||||||
|  | 	if (isReloading) return; | ||||||
|  |  | ||||||
| 	window.clearTimeout(timeoutId); | 	window.clearTimeout(timeoutId); | ||||||
| 	timeoutId = window.setTimeout(() => { | 	timeoutId = window.setTimeout(() => { | ||||||
| 		hasDisconnected = true; | 		hasDisconnected = true; | ||||||
|   | |||||||
| @@ -319,7 +319,7 @@ $widgets-hide-threshold: 1090px; | |||||||
| 	min-width: 0; | 	min-width: 0; | ||||||
| 	overflow: auto; | 	overflow: auto; | ||||||
| 	overflow-y: scroll; | 	overflow-y: scroll; | ||||||
| 	overscroll-behavior: contain; | 	overscroll-behavior: none; | ||||||
| 	background: var(--bg); | 	background: var(--bg); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ