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?" | ||||
| beSureToReadThisAsItIsImportant: "Please read this important information." | ||||
| 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: | ||||
|   accountCreated: "Your account was successfully created!" | ||||
|   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; | ||||
|     "youHaveUnreadAnnouncements": string; | ||||
|     "externalServices": string; | ||||
|     "releaseToRefresh": string; | ||||
|     "refreshing": string; | ||||
|     "pullDownToRefresh": string; | ||||
|     "disableStreamingTimeline": string; | ||||
|     "_announcement": { | ||||
|         "forExistingUsers": string; | ||||
|         "forExistingUsersDescription": string; | ||||
|   | ||||
| @@ -1106,6 +1106,10 @@ currentAnnouncements: "現在のお知らせ" | ||||
| pastAnnouncements: "過去のお知らせ" | ||||
| youHaveUnreadAnnouncements: "未読のお知らせがあります。" | ||||
| externalServices: "外部サービス" | ||||
| releaseToRefresh: "離してリロード" | ||||
| refreshing: "リロード中" | ||||
| pullDownToRefresh: "引っ張ってリロード" | ||||
| disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" | ||||
|  | ||||
| _announcement: | ||||
|   forExistingUsers: "既存ユーザーのみ" | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import { common } from './common'; | ||||
| import { version, ui, lang, updateLocale } from '@/config'; | ||||
| import { i18n, updateI18n } from '@/i18n'; | ||||
| import { confirm, alert, post, popup, toast } from '@/os'; | ||||
| import { useStream } from '@/stream'; | ||||
| import { useStream, isReloading } from '@/stream'; | ||||
| import * as sound from '@/scripts/sound'; | ||||
| import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; | ||||
| import { defaultStore, ColdDeviceStorage } from '@/store'; | ||||
| @@ -39,6 +39,7 @@ export async function mainBoot() { | ||||
|  | ||||
| 	let reloadDialogShowing = false; | ||||
| 	stream.on('_disconnected_', async () => { | ||||
| 		if (isReloading) return; | ||||
| 		if (defaultStore.state.serverDisconnectedBehavior === 'reload') { | ||||
| 			location.reload(); | ||||
| 		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <MkPullToRefresh :refresher="() => reload()"> | ||||
| 	<MkPagination ref="pagingComponent" :pagination="pagination"> | ||||
| 		<template #empty> | ||||
| 			<div class="_fullinfo"> | ||||
| @@ -19,11 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			</MkDateSeparatedList> | ||||
| 		</template> | ||||
| 	</MkPagination> | ||||
| </MkPullToRefresh> | ||||
| </template> | ||||
|  | ||||
| <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 MkPullToRefresh from '@/components/MkPullToRefresh.vue'; | ||||
| import XNotification from '@/components/MkNotification.vue'; | ||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.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); | ||||
| 	if (isMuted || document.visibilityState === 'visible') { | ||||
| 		useStream().send('readNotification'); | ||||
| 	} | ||||
|  | ||||
| 	if (!isMuted) { | ||||
| 		pagingComponent.value.prepend(notification); | ||||
| 		pagingComponent.value?.prepend(notification); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function reload() { | ||||
| 	return new Promise<void>((res) => { | ||||
| 		pagingComponent.value?.reload().then(() => { | ||||
| 			res(); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
| }; | ||||
|  | ||||
| let connection; | ||||
|  | ||||
| @@ -66,6 +77,12 @@ onMounted(() => { | ||||
| 	connection.on('notification', onNotification); | ||||
| }); | ||||
|  | ||||
| onActivated(() => { | ||||
| 	pagingComponent.value?.reload(); | ||||
| 	connection = useStream().useChannel('main'); | ||||
| 	connection.on('notification', onNotification); | ||||
| }); | ||||
|  | ||||
| onUnmounted(() => { | ||||
| 	if (connection) connection.dispose(); | ||||
| }); | ||||
|   | ||||
| @@ -166,6 +166,8 @@ defineExpose({ | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	overscroll-behavior: none; | ||||
|  | ||||
| 	min-height: 100%; | ||||
| 	background: var(--bg); | ||||
|  | ||||
|   | ||||
| @@ -90,6 +90,7 @@ const props = withDefaults(defineProps<{ | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'queue', count: number): void; | ||||
| 	(ev: 'status', error: boolean): void; | ||||
| }>(); | ||||
|  | ||||
| let rootEl = $shallowRef<HTMLElement>(); | ||||
| @@ -164,6 +165,11 @@ watch(queue, (a, b) => { | ||||
| 	emit('queue', queue.value.length); | ||||
| }, { deep: true }); | ||||
|  | ||||
| watch(error, (n, o) => { | ||||
| 	if (n === o) return; | ||||
| 	emit('status', n); | ||||
| }); | ||||
|  | ||||
| async function init(): Promise<void> { | ||||
| 	queue.value = []; | ||||
| 	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> | ||||
| <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> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed, provide, onUnmounted } from '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 { $i } from '@/account'; | ||||
| import { defaultStore } from '@/store'; | ||||
| @@ -31,6 +34,7 @@ const emit = defineEmits<{ | ||||
|  | ||||
| provide('inChannel', computed(() => props.src === 'channel')); | ||||
|  | ||||
| const prComponent: InstanceType<typeof MkPullToRefresh> = $ref(); | ||||
| const tlComponent: InstanceType<typeof MkNotes> = $ref(); | ||||
|  | ||||
| const prepend = note => { | ||||
| @@ -49,21 +53,13 @@ let connection; | ||||
| let connection2; | ||||
|  | ||||
| const stream = useStream(); | ||||
|  | ||||
| const connectChannel = () => { | ||||
| 	if (props.src === 'antenna') { | ||||
| 	endpoint = 'antennas/notes'; | ||||
| 	query = { | ||||
| 		antennaId: props.antenna, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('antenna', { | ||||
| 			antennaId: props.antenna, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'home') { | ||||
| 	endpoint = 'notes/timeline'; | ||||
| 	query = { | ||||
| 		withReplies: defaultStore.state.showTimelineReplies, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('homeTimeline', { | ||||
| 			withReplies: defaultStore.state.showTimelineReplies, | ||||
| 		}); | ||||
| @@ -71,52 +67,30 @@ if (props.src === 'antenna') { | ||||
|  | ||||
| 		connection2 = stream.useChannel('main'); | ||||
| 	} else if (props.src === 'local') { | ||||
| 	endpoint = 'notes/local-timeline'; | ||||
| 	query = { | ||||
| 		withReplies: defaultStore.state.showTimelineReplies, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('localTimeline', { | ||||
| 			withReplies: defaultStore.state.showTimelineReplies, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'media') { | ||||
| 	endpoint = 'notes/hybrid-timeline'; | ||||
| 	query = { | ||||
| 		withFiles: true, | ||||
| 		withReplies: defaultStore.state.showTimelineReplies, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('hybridTimeline', { | ||||
| 			withFiles: true, | ||||
| 			withReplies: defaultStore.state.showTimelineReplies, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'social') { | ||||
| 	endpoint = 'notes/hybrid-timeline'; | ||||
| 	query = { | ||||
| 		withReplies: defaultStore.state.showTimelineReplies, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('hybridTimeline', { | ||||
| 			withReplies: defaultStore.state.showTimelineReplies, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'global') { | ||||
| 	endpoint = 'notes/global-timeline'; | ||||
| 	query = { | ||||
| 		withReplies: defaultStore.state.showTimelineReplies, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('globalTimeline', { | ||||
| 			withReplies: defaultStore.state.showTimelineReplies, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'mentions') { | ||||
| 	endpoint = 'notes/mentions'; | ||||
| 		connection = stream.useChannel('main'); | ||||
| 		connection.on('mention', prepend); | ||||
| 	} else if (props.src === 'directs') { | ||||
| 	endpoint = 'notes/mentions'; | ||||
| 	query = { | ||||
| 		visibility: 'specified', | ||||
| 	}; | ||||
| 		const onNote = note => { | ||||
| 			if (note.visibility === 'specified') { | ||||
| 				prepend(note); | ||||
| @@ -125,33 +99,86 @@ if (props.src === 'antenna') { | ||||
| 		connection = stream.useChannel('main'); | ||||
| 		connection.on('mention', onNote); | ||||
| 	} else if (props.src === 'list') { | ||||
| 	endpoint = 'notes/user-list-timeline'; | ||||
| 	query = { | ||||
| 		listId: props.list, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('userList', { | ||||
| 			listId: props.list, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'channel') { | ||||
| 	endpoint = 'channels/timeline'; | ||||
| 	query = { | ||||
| 		channelId: props.channel, | ||||
| 	}; | ||||
| 		connection = stream.useChannel('channel', { | ||||
| 			channelId: props.channel, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'role') { | ||||
| 	endpoint = 'roles/notes'; | ||||
| 	query = { | ||||
| 		roleId: props.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') { | ||||
| 	endpoint = 'notes/user-list-timeline'; | ||||
| 	query = { | ||||
| 		listId: props.list, | ||||
| 	}; | ||||
| } else if (props.src === 'channel') { | ||||
| 	endpoint = 'channels/timeline'; | ||||
| 	query = { | ||||
| 		channelId: props.channel, | ||||
| 	}; | ||||
| } else if (props.src === 'role') { | ||||
| 	endpoint = 'roles/notes'; | ||||
| 	query = { | ||||
| 		roleId: props.role, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| if (!defaultStore.state.disableStreamingTimeline) { | ||||
| 	connectChannel(); | ||||
|  | ||||
| 	onUnmounted(() => { | ||||
| 		connection.dispose(); | ||||
| 		if (connection2) connection2.dispose(); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| const pagination = { | ||||
| 	endpoint: endpoint, | ||||
| @@ -159,15 +186,16 @@ const pagination = { | ||||
| 	params: query, | ||||
| }; | ||||
|  | ||||
| onUnmounted(() => { | ||||
| 	connection.dispose(); | ||||
| 	if (connection2) connection2.dispose(); | ||||
| function reloadTimeline() { | ||||
| 	return new Promise<void>((res) => { | ||||
| 		tlComponent.pagingComponent?.reload().then(() => { | ||||
| 			reloadStream(); | ||||
| 			res(); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| /* TODO | ||||
| const timetravel = (date?: Date) => { | ||||
| 	this.date = date; | ||||
| 	this.$refs.tl.reload(); | ||||
| }; | ||||
| */ | ||||
| defineExpose({ | ||||
| 	reloadTimeline, | ||||
| }); | ||||
| </script> | ||||
|   | ||||
| @@ -135,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			<div class="_gaps_s"> | ||||
| 				<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> | ||||
| 				<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> | ||||
| 				<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> | ||||
| 			</div> | ||||
| 			<MkSelect v-model="serverDisconnectedBehavior"> | ||||
| 				<template #label>{{ i18n.ts.whenServerDisconnected }}</template> | ||||
| @@ -231,6 +232,7 @@ const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter(' | ||||
| const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); | ||||
| const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); | ||||
| const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies')); | ||||
| const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); | ||||
|  | ||||
| watch(lang, () => { | ||||
| 	miLocalStorage.setItem('lang', lang.value as string); | ||||
| @@ -264,6 +266,7 @@ watch([ | ||||
| 	instanceTicker, | ||||
| 	overridedDeviceKind, | ||||
| 	mediaListWithOneImageAppearance, | ||||
| 	disableStreamingTimeline, | ||||
| ], async () => { | ||||
| 	await reloadAsk(); | ||||
| }); | ||||
|   | ||||
| @@ -38,6 +38,7 @@ import { i18n } from '@/i18n'; | ||||
| import { instance } from '@/instance'; | ||||
| import { $i } from '@/account'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { deviceKind } from '@/scripts/device-kind.js'; | ||||
|  | ||||
| provide('shouldOmitHeaderTitle', true); | ||||
|  | ||||
| @@ -121,7 +122,13 @@ function focus(): void { | ||||
| 	tlComponent.focus(); | ||||
| } | ||||
|  | ||||
| const headerActions = $computed(() => []); | ||||
| const headerActions = $computed(() => [ | ||||
| 	...[deviceKind === 'desktop' ? { | ||||
| 		icon: 'ti ti-refresh', | ||||
| 		text: i18n.ts.reload, | ||||
| 		handler: () => { tlComponent.reloadTimeline(); }, | ||||
| 	} : {}], | ||||
| ]); | ||||
|  | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'home', | ||||
|   | ||||
| @@ -351,6 +351,10 @@ export const defaultStore = markRaw(new Storage('base', { | ||||
| 		where: 'device', | ||||
| 		default: {} as Record<string, Record<string, string[]>>, | ||||
| 	}, | ||||
| 	disableStreamingTimeline: { | ||||
| 		where: 'device', | ||||
| 		default: false, | ||||
| 	}, | ||||
| })); | ||||
|  | ||||
| // TODO: 他のタブと永続化されたstateを同期 | ||||
|   | ||||
| @@ -9,6 +9,9 @@ import { $i } from '@/account'; | ||||
| import { url } from '@/config'; | ||||
|  | ||||
| let stream: Misskey.Stream | null = null; | ||||
| let timeoutHeartBeat: number | null = null; | ||||
|  | ||||
| export let isReloading: boolean = false; | ||||
|  | ||||
| export function useStream(): Misskey.Stream { | ||||
| 	if (stream) return stream; | ||||
| @@ -17,7 +20,20 @@ export function useStream(): Misskey.Stream { | ||||
| 		token: $i.token, | ||||
| 	} : 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; | ||||
| } | ||||
| @@ -26,5 +42,5 @@ function heartbeat(): void { | ||||
| 	if (stream != null && document.visibilityState === 'visible') { | ||||
| 		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> | ||||
| import { onUnmounted } from 'vue'; | ||||
| import { useStream } from '@/stream'; | ||||
| import { useStream, isReloading } from '@/stream'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import * as os from '@/os'; | ||||
| @@ -27,6 +27,8 @@ let hasDisconnected = $ref(false); | ||||
| let timeoutId = $ref<number>(); | ||||
|  | ||||
| function onDisconnected() { | ||||
| 	if (isReloading) return; | ||||
|  | ||||
| 	window.clearTimeout(timeoutId); | ||||
| 	timeoutId = window.setTimeout(() => { | ||||
| 		hasDisconnected = true; | ||||
|   | ||||
| @@ -319,7 +319,7 @@ $widgets-hide-threshold: 1090px; | ||||
| 	min-width: 0; | ||||
| 	overflow: auto; | ||||
| 	overflow-y: scroll; | ||||
| 	overscroll-behavior: contain; | ||||
| 	overscroll-behavior: none; | ||||
| 	background: var(--bg); | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ