Compare commits
	
		
			126 Commits
		
	
	
		
			2024.11.0-
			...
			pag-back
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | b312f360c5 | ||
|   | e93c58ffa4 | ||
|   | 4abe7e79a9 | ||
|   | f34bcf0c1d | ||
|   | fb5bb950de | ||
|   | ac96aba0f5 | ||
|   | 9a352d4949 | ||
|   | 79934c7931 | ||
|   | 0994adc748 | ||
|   | 0449c1a7ea | ||
|   | f3da1bcbbd | ||
|   | 7fc2309822 | ||
|   | d92fe0803c | ||
|   | ae949af6c3 | ||
|   | f7ddff7475 | ||
|   | 848b72ae21 | ||
|   | dab76b5e77 | ||
|   | 4785b9bfdd | ||
|   | 9f79e494f5 | ||
|   | 51cf5c57f0 | ||
|   | 9f4df717e8 | ||
|   | cf764eebe3 | ||
|   | d6c3f34eea | ||
|   | c265569008 | ||
|   | d6e57059e4 | ||
|   | 7ccdd503b7 | ||
|   | 560a1fecf5 | ||
|   | ef69eee155 | ||
|   | 20ae59756f | ||
|   | 3cc22e5e1c | ||
|   | 4e775a670f | ||
|   | e6ee5704e8 | ||
|   | d0a119c2ea | ||
|   | ee1e2aa200 | ||
|   | a2f6bf3d5c | ||
|   | 4c83663597 | ||
|   | 4e7a26e6d5 | ||
|   | 660b030233 | ||
|   | fc50dc7a67 | ||
|   | 05042a0697 | ||
|   | 5bfb98df00 | ||
|   | b02187d9d0 | ||
|   | e2f3091778 | ||
|   | 18611ab521 | ||
|   | e8316dc4c4 | ||
|   | 72ae8441e1 | ||
|   | 4aee99b61a | ||
|   | e9486d0085 | ||
|   | 7e06305b96 | ||
|   | 94f9ebc80c | ||
|   | f7d776e4da | ||
|   | d5b4fa7e50 | ||
|   | f3a0839552 | ||
|   | b0c6675ef3 | ||
|   | 72998adfb6 | ||
|   | 954d934505 | ||
|   | 4cd9623dc3 | ||
|   | 1ccac0c1e3 | ||
|   | 7895474263 | ||
|   | fd44a29f2b | ||
|   | 054ea30955 | ||
|   | dd02648f8d | ||
|   | 81238fabd2 | ||
|   | 3677a91c4a | ||
|   | b2c1f5873d | ||
|   | 76145701af | ||
|   | 0079f3394b | ||
|   | cb63a1ed00 | ||
|   | 1062371296 | ||
|   | 3f6f6a49b6 | ||
|   | d73ea541bf | ||
|   | f7425f5fe9 | ||
|   | b60dba701c | ||
|   | b5f85aa9a8 | ||
|   | 6152122d43 | ||
|   | d335da5ee4 | ||
|   | d82d03890d | ||
|   | 4881237955 | ||
|   | fc91526857 | ||
|   | da4aba3247 | ||
|   | 568822944f | ||
|   | 393160eeda | ||
|   | 0f64372abb | ||
|   | 02054528f9 | ||
|   | 31b62db14b | ||
|   | 41824ae383 | ||
|   | 5a5ef7564a | ||
|   | 78944bf441 | ||
|   | f565e0f8a5 | ||
|   | bec510e37d | ||
|   | b446bfb0b6 | ||
|   | 3bbeac4be2 | ||
|   | e7251220d5 | ||
|   | 4bef4953b8 | ||
|   | e609b3b7dc | ||
|   | 7fe882d0e2 | ||
|   | b330ede502 | ||
|   | f30275a975 | ||
|   | 04ff07e4e7 | ||
|   | 7d4f33d2c0 | ||
|   | 2a434c63df | ||
|   | a1b90d6dd3 | ||
|   | c7c3c32871 | ||
|   | 4fabe26b07 | ||
|   | 752c01ba91 | ||
|   | ba3fa8b431 | ||
|   | 2bbada3cd4 | ||
|   | a26f289dd5 | ||
|   | 8213380ded | ||
|   | 68d647d6b8 | ||
|   | 130ece74f9 | ||
|   | fae912a754 | ||
|   | 877a7a81bb | ||
|   | 88315d3e80 | ||
|   | af00c2c96c | ||
|   | 974f7c13d3 | ||
|   | 44dee0f883 | ||
|   | 794ff58b07 | ||
|   | f5a019a6d6 | ||
|   | ddb41bd0ba | ||
|   | 035c98dc15 | ||
|   | b4d532efb4 | ||
|   | 28f914f67f | ||
|   | 2481123972 | ||
|   | 5f1cd1e532 | ||
|   | 9f246e3dc7 | 
							
								
								
									
										7
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -533,7 +533,7 @@ export interface Locale { | ||||
|     "deleteAll": string; | ||||
|     "showFixedPostForm": string; | ||||
|     "showFixedPostFormInChannel": string; | ||||
|     "newNoteRecived": string; | ||||
|     "goToTheHeadOfTimeline": string; | ||||
|     "sounds": string; | ||||
|     "sound": string; | ||||
|     "listen": string; | ||||
| @@ -1103,6 +1103,7 @@ export interface Locale { | ||||
|     "doYouAgree": string; | ||||
|     "beSureToReadThisAsItIsImportant": string; | ||||
|     "iHaveReadXCarefullyAndAgree": string; | ||||
|     "timelineBackTopBehavior": string; | ||||
|     "dialog": string; | ||||
|     "icon": string; | ||||
|     "forYou": string; | ||||
| @@ -1672,6 +1673,10 @@ export interface Locale { | ||||
|         "dialog": string; | ||||
|         "quiet": string; | ||||
|     }; | ||||
|     "_timelineBackTopBehavior": { | ||||
|         "newest": string; | ||||
|         "next": string; | ||||
|     }; | ||||
|     "_channel": { | ||||
|         "create": string; | ||||
|         "edit": string; | ||||
|   | ||||
| @@ -530,7 +530,7 @@ serverLogs: "サーバーログ" | ||||
| deleteAll: "全て削除" | ||||
| showFixedPostForm: "タイムライン上部に投稿フォームを表示する" | ||||
| showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" | ||||
| newNoteRecived: "新しいノートがあります" | ||||
| goToTheHeadOfTimeline: "最新のノートに移動" | ||||
| sounds: "サウンド" | ||||
| sound: "サウンド" | ||||
| listen: "聴く" | ||||
| @@ -1100,6 +1100,7 @@ expired: "期限切れ" | ||||
| doYouAgree: "同意しますか?" | ||||
| beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。" | ||||
| iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。" | ||||
| timelineBackTopBehavior: "タイムラインのスクロールが先頭に戻った時の挙動" | ||||
| dialog: "ダイアログ" | ||||
| icon: "アイコン" | ||||
| forYou: "あなたへ" | ||||
| @@ -1589,6 +1590,10 @@ _serverDisconnectedBehavior: | ||||
|   dialog: "ダイアログで警告" | ||||
|   quiet: "控えめに警告" | ||||
|  | ||||
| _timelineBackTopBehavior: | ||||
|   newest: "最新の投稿を表示" | ||||
|   next: "次の投稿を遡る" | ||||
|  | ||||
| _channel: | ||||
|   create: "チャンネルを作成" | ||||
|   edit: "チャンネルを編集" | ||||
|   | ||||
| @@ -68,6 +68,7 @@ | ||||
| 		"tsconfig-paths": "4.2.0", | ||||
| 		"twemoji-parser": "14.0.0", | ||||
| 		"typescript": "5.2.2", | ||||
| 		"ua-parser-js": "2.0.0-alpha.2", | ||||
| 		"uuid": "9.0.1", | ||||
| 		"vanilla-tilt": "1.8.1", | ||||
| 		"vite": "4.4.9", | ||||
|   | ||||
| @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue'; | ||||
| import { defineComponent, h, PropType, TransitionGroup, useCssModule, watch } from 'vue'; | ||||
| import MkAd from '@/components/global/MkAd.vue'; | ||||
| import { isDebuggerEnabled, stackTraceInstances } from '@/debug'; | ||||
| import { i18n } from '@/i18n'; | ||||
| @@ -38,6 +38,11 @@ export default defineComponent({ | ||||
| 			required: false, | ||||
| 			default: false, | ||||
| 		}, | ||||
| 		denyMoveTransition: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	setup(props, { slots, expose }) { | ||||
| @@ -135,6 +140,7 @@ export default defineComponent({ | ||||
| 					[$style['reversed']]: props.reversed, | ||||
| 					[$style['direction-down']]: props.direction === 'down', | ||||
| 					[$style['direction-up']]: props.direction === 'up', | ||||
| 					'deny-move-transition': props.denyMoveTransition, | ||||
| 				}, | ||||
| 				...(defaultStore.state.animation ? { | ||||
| 					name: 'list', | ||||
| @@ -153,15 +159,11 @@ export default defineComponent({ | ||||
| 	container-type: inline-size; | ||||
|  | ||||
| 	&:global { | ||||
| 	> .list-move { | ||||
| 	&:not(.deny-move-transition) > .list-move { | ||||
| 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); | ||||
| 	} | ||||
|  | ||||
| 	&.deny-move-transition > .list-move { | ||||
| 		transition: none !important; | ||||
| 	} | ||||
|  | ||||
| 	> .list-enter-active { | ||||
| 	&:not(.deny-move-transition) > .list-enter-active { | ||||
| 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template #default="{ items: notes }"> | ||||
| 	<template #default="{ items: notes, denyMoveTransition }"> | ||||
| 		<div :class="[$style.root, { [$style.noGap]: noGap }]"> | ||||
| 			<MkDateSeparatedList | ||||
| 				ref="notes" | ||||
| @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 				:noGap="noGap" | ||||
| 				:ad="true" | ||||
| 				:class="$style.notes" | ||||
| 				:denyMoveTransition="denyMoveTransition" | ||||
| 			> | ||||
| 				<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> | ||||
| 			</MkDateSeparatedList> | ||||
|   | ||||
| @@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | ||||
| 	<template #default="{ items: notifications }"> | ||||
| 		<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> | ||||
| 			<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | ||||
| 	<template #default="{ items: notifications, denyMoveTransition }"> | ||||
| 		<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition"> | ||||
| 			<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="`showNotificationAsNote:${notification.id}`" :note="notification.note"/> | ||||
| 			<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/> | ||||
| 		</MkDateSeparatedList> | ||||
| 	</template> | ||||
| @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onUnmounted, onMounted, computed, shallowRef } from 'vue'; | ||||
| import { onUnmounted, onMounted, computed, shallowRef, watch } from 'vue'; | ||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
| import XNotification from '@/components/MkNotification.vue'; | ||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||
| @@ -55,10 +55,16 @@ const onNotification = (notification) => { | ||||
| 	} | ||||
|  | ||||
| 	if (!isMuted) { | ||||
| 		pagingComponent.value.prepend(notification); | ||||
| 		pagingComponent.value?.prepend(notification); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| watch(() => pagingComponent.value?.backed, (backed) => { | ||||
| 	if (backed === false) { | ||||
| 		useStream().send('readNotification'); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| let connection; | ||||
|  | ||||
| onMounted(() => { | ||||
|   | ||||
| @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</template> | ||||
|  | ||||
| 	<div ref="contents" :class="$style.root" style="container-type: inline-size;"> | ||||
| 		<RouterView :key="reloadCount" :router="router"/> | ||||
| 		<RouterView :key="reloadCount" :router="router" :scrollContainer="contents"/> | ||||
| 	</div> | ||||
| </MkWindow> | ||||
| </template> | ||||
| @@ -37,12 +37,11 @@ import copyToClipboard from '@/scripts/copy-to-clipboard'; | ||||
| import { url } from '@/config'; | ||||
| import { mainRouter, routes, page } from '@/router'; | ||||
| import { $i } from '@/account'; | ||||
| import { Router, useScrollPositionManager } from '@/nirax'; | ||||
| import { Router } from '@/nirax'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | ||||
| import { openingWindowsCount } from '@/os'; | ||||
| import { claimAchievement } from '@/scripts/achievements'; | ||||
| import { getScrollContainer } from '@/scripts/scroll'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	initialPath: string; | ||||
| @@ -146,8 +145,6 @@ function popout() { | ||||
| 	windowEl.close(); | ||||
| } | ||||
|  | ||||
| useScrollPositionManager(() => getScrollContainer(contents.value), router); | ||||
|  | ||||
| onMounted(() => { | ||||
| 	openingWindowsCount.value++; | ||||
| 	if (openingWindowsCount.value >= 3) { | ||||
|   | ||||
| @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| > | ||||
| 	<MkLoading v-if="fetching"/> | ||||
|  | ||||
| 	<MkError v-else-if="error" @retry="init()"/> | ||||
| 	<MkError v-else-if="empty && error" @retry="reload()"/> | ||||
|  | ||||
| 	<div v-else-if="empty" key="_empty_" class="empty"> | ||||
| 		<slot name="empty"> | ||||
| @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			</MkButton> | ||||
| 			<MkLoading v-else class="loading"/> | ||||
| 		</div> | ||||
| 		<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> | ||||
| 		<slot :items="providingItems" :fetching="fetching || moreFetching" :denyMoveTransition="denyMoveTransition"></slot> | ||||
| 		<div v-show="!pagination.reversed && more" key="_more_" class="_margin"> | ||||
| 			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> | ||||
| 				{{ i18n.ts.loadMore }} | ||||
| @@ -46,20 +46,31 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import * as os from '@/os'; | ||||
| import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll'; | ||||
| import { isBottomVisible, isTopVisible, getScrollContainer, scrollToBottom, scrollToTop, scrollBy, scroll, getBodyScrollHeight } from '@/scripts/scroll'; | ||||
| import { useDocumentVisibility } from '@/scripts/use-document-visibility'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { MisskeyEntity } from '@/types/date-separated-list'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { isWebKit } from '@/scripts/useragent'; | ||||
|  | ||||
| const SECOND_FETCH_LIMIT = 30; | ||||
| const TOLERANCE = 16; | ||||
| const TOLERANCE = 6; | ||||
| const APPEAR_MINIMUM_INTERVAL = 600; | ||||
|  | ||||
| export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { | ||||
| 	endpoint: E; | ||||
|  | ||||
| 	/** | ||||
| 	 * 一度にAPIへ取得する件数 | ||||
| 	 */ | ||||
| 	limit: number; | ||||
|  | ||||
| 	/** | ||||
| 	 * タイムラインに表示する最大件数 | ||||
| 	 */ | ||||
| 	displayLimit?: number; | ||||
|  | ||||
| 	params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>; | ||||
|  | ||||
| 	/** | ||||
| @@ -87,6 +98,8 @@ function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] { | ||||
| function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap { | ||||
| 	return new Map([...map, ...arrayToEntries(entities)]); | ||||
| } | ||||
|  | ||||
| const timelineBackTopBehavior = computed(() => isWebKit() ? 'newest' : defaultStore.reactiveState.timelineBackTopBehavior.value); | ||||
| </script> | ||||
| <script lang="ts" setup> | ||||
| import { infoImageUrl } from '@/instance'; | ||||
| @@ -94,19 +107,19 @@ import { infoImageUrl } from '@/instance'; | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	pagination: Paging; | ||||
| 	disableAutoLoad?: boolean; | ||||
| 	displayLimit?: number; | ||||
| }>(), { | ||||
| 	displayLimit: 20, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'queue', count: number): void; | ||||
| }>(); | ||||
|  | ||||
| let rootEl = $shallowRef<HTMLElement>(); | ||||
|  | ||||
| // 遡り中かどうか | ||||
| /** | ||||
|  * スクロールが先頭にある場合はfalse | ||||
|  * スクロールが先頭にない場合にtrue | ||||
|  */ | ||||
| // 先頭にいるか(prependでキューに追加するかどうかの判定に使う) | ||||
| let backed = $ref(false); | ||||
| // true→falseの変更でexecuteQueueする | ||||
| let weakBacked = $ref(false); | ||||
|  | ||||
| let scrollRemove = $ref<(() => void) | null>(null); | ||||
|  | ||||
| @@ -115,12 +128,14 @@ let scrollRemove = $ref<(() => void) | null>(null); | ||||
|  * 最新が0番目 | ||||
|  */ | ||||
| const items = ref<MisskeyEntityMap>(new Map()); | ||||
| const providingItems = computed(() => Array.from(items.value.values())); | ||||
|  | ||||
| /** | ||||
|  * タブが非アクティブなどの場合に更新を貯めておく | ||||
|  * 最新が0番目 | ||||
|  * 最新が最後(パフォーマンス上の理由でitemsと逆にした) | ||||
|  */ | ||||
| const queue = ref<MisskeyEntityMap>(new Map()); | ||||
| const queueSize = computed(() => queue.value.size); | ||||
|  | ||||
| const offset = ref(0); | ||||
|  | ||||
| @@ -129,69 +144,153 @@ const offset = ref(0); | ||||
|  */ | ||||
| const fetching = ref(true); | ||||
|  | ||||
| /** | ||||
|  * onActivatedでtrue, onDeactivatedでfalseになる | ||||
|  */ | ||||
| const active = ref(true); | ||||
|  | ||||
| const moreFetching = ref(false); | ||||
| const more = ref(false); | ||||
| const preventAppearFetchMore = ref(false); | ||||
| const preventAppearFetchMoreTimer = ref<number | null>(null); | ||||
| const isBackTop = ref(false); | ||||
| const empty = computed(() => items.value.size === 0); | ||||
| const error = ref(false); | ||||
| const { | ||||
| 	enableInfiniteScroll, | ||||
| } = defaultStore.reactiveState; | ||||
|  | ||||
| const displayLimit = computed(() => props.pagination.displayLimit ?? props.pagination.limit * 2); | ||||
|  | ||||
| const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); | ||||
| const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body); | ||||
| const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) ?? null : null); | ||||
| const scrollableElementOrHtml = $computed(() => scrollableElement ?? document.getElementsByName('html')[0]); | ||||
|  | ||||
| const visibility = useDocumentVisibility(); | ||||
|  | ||||
| let isPausingUpdate = false; | ||||
| let timerForSetPause: number | null = null; | ||||
| const BACKGROUND_PAUSE_WAIT_SEC = 10; | ||||
| const isPausingUpdateByExecutingQueue = ref(false); | ||||
| const denyMoveTransition = ref(false); | ||||
|  | ||||
| // 先頭が表示されているかどうかを検出 | ||||
| // https://qiita.com/mkataigi/items/0154aefd2223ce23398e | ||||
| //#region scrolling | ||||
| const checkFn = props.pagination.reversed ? isBottomVisible : isTopVisible; | ||||
| const checkTop = (tolerance?: number) => { | ||||
| 	if (!contentEl) return true; | ||||
| 	if (!document.body.contains(contentEl)) return true; | ||||
| 	return checkFn(contentEl, tolerance, scrollableElement); | ||||
| }; | ||||
| /** | ||||
|  * IntersectionObserverで大まかに検出 | ||||
|  * https://qiita.com/mkataigi/items/0154aefd2223ce23398e | ||||
|  */ | ||||
| let scrollObserver = $ref<IntersectionObserver>(); | ||||
|  | ||||
| watch([() => props.pagination.reversed, $$(scrollableElement)], () => { | ||||
| 	if (scrollObserver) scrollObserver.disconnect(); | ||||
|  | ||||
| 	scrollObserver = new IntersectionObserver(entries => { | ||||
| 		backed = entries[0].isIntersecting; | ||||
| 		if (!active.value) return; // activeでない時は触らない | ||||
| 		weakBacked = entries[0].intersectionRatio >= 0.1; | ||||
| 	}, { | ||||
| 		root: scrollableElement, | ||||
| 		rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', | ||||
| 		threshold: 0.01, | ||||
| 		rootMargin: props.pagination.reversed ? '-100% 0px 1000% 0px' : '1000% 0px -100% 0px', | ||||
| 		threshold: [0.01, 0.05, 0.1, 0.12, 0.15], | ||||
| 	}); | ||||
| }, { immediate: true }); | ||||
|  | ||||
| watch($$(rootEl), () => { | ||||
| watch([$$(rootEl), $$(scrollObserver)], () => { | ||||
| 	scrollObserver?.disconnect(); | ||||
| 	nextTick(() => { | ||||
| 		if (rootEl) scrollObserver?.observe(rootEl); | ||||
| 	}); | ||||
| 	if (rootEl) scrollObserver?.observe(rootEl); | ||||
| }); | ||||
|  | ||||
| watch([$$(backed), $$(contentEl)], () => { | ||||
| 	if (!backed) { | ||||
| 		if (!contentEl) return; | ||||
|  | ||||
| 		scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE); | ||||
| 	} else { | ||||
| 		if (scrollRemove) scrollRemove(); | ||||
| 		scrollRemove = null; | ||||
| /** | ||||
|  * weakBackedがtrue→falseになったらexecuteQueue | ||||
|  */ | ||||
| watch($$(weakBacked), () => { | ||||
| 	if (timelineBackTopBehavior.value === 'next' && !weakBacked) { | ||||
| 		executeQueue(); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| if (props.pagination.params && isRef(props.pagination.params)) { | ||||
| 	watch(props.pagination.params, init, { deep: true }); | ||||
| /** | ||||
|  * backedがtrue→falseになってもexecuteQueue | ||||
|  */ | ||||
| watch($$(backed), () => { | ||||
| 	if (!backed) { | ||||
| 		executeQueue(); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * onScrollTop/onScrollBottomでbackedを厳密に検出する | ||||
|  */ | ||||
| watch([$$(weakBacked), $$(contentEl)], () => { | ||||
| 	if (scrollRemove) scrollRemove(); | ||||
| 	scrollRemove = null; | ||||
|  | ||||
| 	if (weakBacked || !contentEl) { | ||||
| 		if (weakBacked) backed = true; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	scrollRemove = (() => { | ||||
| 		const checkBacked = () => { | ||||
| 			if (!active.value) return; // activeでない時は触らない | ||||
| 			backed = !checkTop(TOLERANCE); | ||||
| 		}; | ||||
|  | ||||
| 		// とりあえず評価してみる | ||||
| 		checkBacked(); | ||||
|  | ||||
| 		const container = scrollableElementOrHtml; | ||||
|  | ||||
| 		function removeListener() { container.removeEventListener('scroll', checkBacked); } | ||||
| 		container.addEventListener('scroll', checkBacked, { passive: true }); | ||||
| 		return removeListener; | ||||
| 	})(); | ||||
| }); | ||||
|  | ||||
| function preventDefault(ev: Event) { | ||||
| 	ev.preventDefault(); | ||||
| } | ||||
|  | ||||
| watch(queue, (a, b) => { | ||||
| 	if (a.size === 0 && b.size === 0) return; | ||||
| 	emit('queue', queue.value.size); | ||||
| }, { deep: true }); | ||||
| /** | ||||
|  * アイテムを上に追加した場合に追加分だけスクロールを下にずらす | ||||
|  * Safariでは使わない方がいいかも? | ||||
|  * @param fn DOM操作(unshiftItemsなど) | ||||
|  */ | ||||
| async function adjustScroll(fn: () => void): Promise<void> { | ||||
| 	await nextTick(); | ||||
| 	const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); | ||||
| 	const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; | ||||
| 	// スクロールをやめさせる | ||||
| 	try { | ||||
| 		// なぜかscrollableElementOrHtmlがundefinedであるというエラーが出る | ||||
| 		scrollableElementOrHtml.addEventListener('wheel', preventDefault, { passive: false }); | ||||
| 		scrollableElementOrHtml.addEventListener('touchmove', preventDefault, { passive: false }); | ||||
| 		// スクロールを強制的に停止 | ||||
| 		scroll(scrollableElement, { top: oldScroll, behavior: 'instant' }); | ||||
| 	} catch (err) { | ||||
| 		console.error(err, { scrollableElementOrHtml }); | ||||
| 	} | ||||
| 	denyMoveTransition.value = true; | ||||
| 	fn(); | ||||
| 	return await nextTick().then(() => { | ||||
| 		const top = oldScroll + ((scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight()) - oldHeight); | ||||
| 		scroll(scrollableElement, { top, behavior: 'instant' }); | ||||
| 		// なぜかscrollableElementOrHtmlがundefinedであるというエラーが出る | ||||
| 		scrollableElementOrHtml.removeEventListener('wheel', preventDefault); | ||||
| 		scrollableElementOrHtml.removeEventListener('touchmove', preventDefault); | ||||
| 	}).then(() => nextTick()).finally(() => { | ||||
| 		denyMoveTransition.value = false; | ||||
| 	}); | ||||
| } | ||||
| //#endregion | ||||
|  | ||||
| /** | ||||
|  * 初期化 | ||||
|  * scrollAfterInitなどの後処理もあるので、reload関数を使うべき | ||||
|  *  | ||||
|  * 注意: moreFetchingをtrueにするのでfalseにする必要がある | ||||
|  */ | ||||
| async function init(): Promise<void> { | ||||
| 	items.value = new Map(); | ||||
| 	queue.value = new Map(); | ||||
| @@ -210,7 +309,7 @@ async function init(): Promise<void> { | ||||
| 			concatItems(res); | ||||
| 			more.value = false; | ||||
| 		} else { | ||||
| 			if (props.pagination.reversed) moreFetching.value = true; | ||||
| 			moreFetching.value = true; | ||||
| 			concatItems(res); | ||||
| 			more.value = true; | ||||
| 		} | ||||
| @@ -224,10 +323,50 @@ async function init(): Promise<void> { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| const reload = (): Promise<void> => { | ||||
| 	return init(); | ||||
| /** | ||||
|  * initの後に呼ぶ | ||||
|  * コンポーネント作成直後でinitが呼ばれた時はonMountedで呼ばれる | ||||
|  * reloadでinitが呼ばれた時はreload内でinitの後に呼ばれる | ||||
|  */ | ||||
| function scrollAfterInit() { | ||||
| 	if (props.pagination.reversed) { | ||||
| 		nextTick(() => { | ||||
| 			setTimeout(async () => { | ||||
| 				if (contentEl) { | ||||
| 					scrollToBottom(contentEl); | ||||
| 					// scrollToしてもbacked周りがうまく動かないので手動で戻す必要がある | ||||
| 					weakBacked = false; | ||||
| 				} | ||||
| 			}, 200); | ||||
|  | ||||
| 			// scrollToBottomでmoreFetchingボタンが画面外まで出るまで | ||||
| 			// more = trueを遅らせる | ||||
| 			setTimeout(() => { | ||||
| 				moreFetching.value = false; | ||||
| 			}, 2000); | ||||
| 		}); | ||||
| 	} else { | ||||
| 		nextTick(() => { | ||||
| 			setTimeout(() => { | ||||
| 				scrollToTop(scrollableElement); | ||||
| 				// scrollToしてもbacked周りがうまく動かないので手動で戻す必要がある | ||||
| 				weakBacked = false; | ||||
|  | ||||
| 				moreFetching.value = false; | ||||
| 			}, 200); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| const reload = async (): Promise<void> => { | ||||
| 	await init(); | ||||
| 	scrollAfterInit(); | ||||
| }; | ||||
|  | ||||
| if (props.pagination.params && isRef(props.pagination.params)) { | ||||
| 	watch(props.pagination.params, reload, { deep: true }); | ||||
| } | ||||
|  | ||||
| const fetchMore = async (): Promise<void> => { | ||||
| 	if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; | ||||
| 	moreFetching.value = true; | ||||
| @@ -246,29 +385,13 @@ const fetchMore = async (): Promise<void> => { | ||||
| 			if (i === 10) item._shouldInsertAd_ = true; | ||||
| 		} | ||||
|  | ||||
| 		const reverseConcat = _res => { | ||||
| 			const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); | ||||
| 			const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; | ||||
|  | ||||
| 			items.value = concatMapWithArray(items.value, _res); | ||||
|  | ||||
| 			return nextTick(() => { | ||||
| 				if (scrollableElement) { | ||||
| 					scroll(scrollableElement, { top: oldScroll + (scrollableElement.scrollHeight - oldHeight), behavior: 'instant' }); | ||||
| 				} else { | ||||
| 					window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); | ||||
| 				} | ||||
|  | ||||
| 				return nextTick(); | ||||
| 			}); | ||||
| 		}; | ||||
| 		const reverseConcat = (_res) => adjustScroll(() => concatMapWithArray(items.value, _res)); | ||||
|  | ||||
| 		if (res.length === 0) { | ||||
| 			if (props.pagination.reversed) { | ||||
| 				reverseConcat(res).then(() => { | ||||
| 					more.value = false; | ||||
| 					moreFetching.value = false; | ||||
| 				}); | ||||
| 				reverseConcat(res); | ||||
| 				more.value = false; | ||||
| 				moreFetching.value = false; | ||||
| 			} else { | ||||
| 				items.value = concatMapWithArray(items.value, res); | ||||
| 				more.value = false; | ||||
| @@ -276,10 +399,9 @@ const fetchMore = async (): Promise<void> => { | ||||
| 			} | ||||
| 		} else { | ||||
| 			if (props.pagination.reversed) { | ||||
| 				reverseConcat(res).then(() => { | ||||
| 					more.value = true; | ||||
| 					moreFetching.value = false; | ||||
| 				}); | ||||
| 				reverseConcat(res); | ||||
| 				more.value = true; | ||||
| 				moreFetching.value = false; | ||||
| 			} else { | ||||
| 				items.value = concatMapWithArray(items.value, res); | ||||
| 				more.value = true; | ||||
| @@ -344,25 +466,19 @@ const appearFetchMoreAhead = async (): Promise<void> => { | ||||
| 	fetchMoreAppearTimeout(); | ||||
| }; | ||||
|  | ||||
| const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE); | ||||
| onActivated(() => { | ||||
| 	nextTick(() => { | ||||
| 		active.value = true; | ||||
| 	}); | ||||
| }); | ||||
|  | ||||
| watch(visibility, () => { | ||||
| 	if (visibility.value === 'hidden') { | ||||
| 		timerForSetPause = window.setTimeout(() => { | ||||
| 			isPausingUpdate = true; | ||||
| 			timerForSetPause = null; | ||||
| 		}, | ||||
| 		BACKGROUND_PAUSE_WAIT_SEC * 1000); | ||||
| 	} else { // 'visible' | ||||
| 		if (timerForSetPause) { | ||||
| 			clearTimeout(timerForSetPause); | ||||
| 			timerForSetPause = null; | ||||
| 		} else { | ||||
| 			isPausingUpdate = false; | ||||
| 			if (isTop()) { | ||||
| 				executeQueue(); | ||||
| 			} | ||||
| 		} | ||||
| onDeactivated(() => { | ||||
| 	active.value = false; | ||||
| }); | ||||
|  | ||||
| watch([active, visibility], () => { | ||||
| 	if (!backed && active.value && visibility.value === 'visible') { | ||||
| 		executeQueue(); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| @@ -378,19 +494,39 @@ const prepend = (item: MisskeyEntity): void => { | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	if (isTop() && !isPausingUpdate) unshiftItems([item]); | ||||
| 	else prependQueue(item); | ||||
| 	if ( | ||||
| 		!isPausingUpdateByExecutingQueue.value && // スクロール調整中はキューに追加する | ||||
| 		visibility.value !== 'hidden' && // バックグラウンドの場合はキューに追加する | ||||
| 		queueSize.value === 0 && // キューに残っている場合はキューに追加する | ||||
| 		active.value // keepAliveで隠されている間はキューに追加する | ||||
| 	) { | ||||
| 		if (!backed) { | ||||
| 			// かなりスクロールの先頭にいる場合 | ||||
| 			if (items.value.has(item.id)) return; // 既にタイムラインにある場合は何もしない | ||||
| 			unshiftItems([item]); | ||||
| 		} else if (timelineBackTopBehavior.value === 'next' && !weakBacked) { | ||||
| 			// ちょっと先頭にいる場合はスクロールを調整する | ||||
| 			prependQueue(item); | ||||
| 			executeQueue(); | ||||
| 		} else { | ||||
| 			// 先頭にいない場合はキューに追加する | ||||
| 			prependQueue(item); | ||||
| 		} | ||||
| 	} else { | ||||
| 		prependQueue(item); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する | ||||
|  * 新着アイテムをitemsの先頭に追加し、limitを適用する | ||||
|  * @param newItems 新しいアイテムの配列 | ||||
|  * @param limit デフォルトはdisplayLimit | ||||
|  */ | ||||
| function unshiftItems(newItems: MisskeyEntity[]) { | ||||
| function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) { | ||||
| 	const length = newItems.length + items.value.size; | ||||
| 	items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit)); | ||||
| 	items.value = new Map([...arrayToEntries(newItems), ...(newItems.length >= limit ? [] : items.value)].slice(0, limit)); | ||||
|  | ||||
| 	if (length >= props.displayLimit) more.value = true; | ||||
| 	if (length >= limit) more.value = true; | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -399,18 +535,43 @@ function unshiftItems(newItems: MisskeyEntity[]) { | ||||
|  */ | ||||
| function concatItems(oldItems: MisskeyEntity[]) { | ||||
| 	const length = oldItems.length + items.value.size; | ||||
| 	items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit)); | ||||
| 	items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, displayLimit.value)); | ||||
|  | ||||
| 	if (length >= props.displayLimit) more.value = true; | ||||
| 	if (length >= displayLimit.value) more.value = true; | ||||
| } | ||||
|  | ||||
| function executeQueue() { | ||||
| 	unshiftItems(Array.from(queue.value.values())); | ||||
| 	queue.value = new Map(); | ||||
| async function executeQueue() { | ||||
| 	// キューが空の場合でもタイムライン表示数を制限する役割がある | ||||
| 	// ため続行する! | ||||
| 	// if (queue.value.size === 0) return; | ||||
|  | ||||
| 	if (isPausingUpdateByExecutingQueue.value) return; | ||||
| 	if (timelineBackTopBehavior.value === 'newest') { | ||||
| 		// Safariは最新のアイテムにするだけ | ||||
| 		const newItems = Array.from(queue.value.values()).slice(-1 * props.pagination.limit); | ||||
| 		unshiftItems(newItems); | ||||
| 		queue.value = new Map(); | ||||
| 	} else { | ||||
| 		if (queue.value.size > 0) { | ||||
| 			const queueArr = Array.from(queue.value.entries()); | ||||
| 			queue.value = new Map(queueArr.slice(props.pagination.limit)); | ||||
| 			const newItems = Array.from({ length: Math.min(queueArr.length, props.pagination.limit) }, (_, i) => queueArr[i][1]).reverse(); | ||||
| 			isPausingUpdateByExecutingQueue.value = true; | ||||
|  | ||||
| 			await adjustScroll(() => unshiftItems(newItems, Infinity)); | ||||
| 			backed = true; | ||||
| 		} | ||||
|  | ||||
| 		denyMoveTransition.value = true; | ||||
| 		items.value = new Map([...items.value].slice(0, displayLimit.value)); | ||||
| 		await nextTick(); | ||||
| 		isPausingUpdateByExecutingQueue.value = false; | ||||
| 		denyMoveTransition.value = false; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function prependQueue(newItem: MisskeyEntity) { | ||||
| 	queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); | ||||
| 	queue.value.set(newItem.id, newItem); | ||||
| } | ||||
|  | ||||
| /* | ||||
| @@ -435,52 +596,27 @@ const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => M | ||||
|  | ||||
| const inited = init(); | ||||
|  | ||||
| onActivated(() => { | ||||
| 	isBackTop.value = false; | ||||
| }); | ||||
|  | ||||
| onDeactivated(() => { | ||||
| 	isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; | ||||
| }); | ||||
|  | ||||
| function toBottom() { | ||||
| 	scrollToBottom(contentEl!); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| 	inited.then(() => { | ||||
| 		if (props.pagination.reversed) { | ||||
| 			nextTick(() => { | ||||
| 				setTimeout(toBottom, 800); | ||||
|  | ||||
| 				// scrollToBottomでmoreFetchingボタンが画面外まで出るまで | ||||
| 				// more = trueを遅らせる | ||||
| 				setTimeout(() => { | ||||
| 					moreFetching.value = false; | ||||
| 				}, 2000); | ||||
| 			}); | ||||
| 		} | ||||
| 	}); | ||||
| 	active.value = true; | ||||
| 	inited.then(scrollAfterInit); | ||||
| }); | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
| 	if (timerForSetPause) { | ||||
| 		clearTimeout(timerForSetPause); | ||||
| 		timerForSetPause = null; | ||||
| 	} | ||||
| 	if (preventAppearFetchMoreTimer.value) { | ||||
| 		clearTimeout(preventAppearFetchMoreTimer.value); | ||||
| 		preventAppearFetchMoreTimer.value = null; | ||||
| 	} | ||||
| 	scrollObserver?.disconnect(); | ||||
| 	if (scrollRemove) scrollRemove(); | ||||
| }); | ||||
|  | ||||
| defineExpose({ | ||||
| 	items, | ||||
| 	queue, | ||||
| 	backed, | ||||
| 	more, | ||||
| 	inited, | ||||
| 	queueSize, | ||||
| 	backed: $$(backed), | ||||
| 	reload, | ||||
| 	prepend, | ||||
| 	append: appendItem, | ||||
|   | ||||
| @@ -4,7 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> | ||||
| <div> | ||||
| 	<div v-if="queueSize > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="reload()">{{ i18n.ts.goToTheHeadOfTimeline }}</button></div> | ||||
| 	<div v-if="(((src === 'local' || src === 'social') && !isLocalTimelineAvailable) || (src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> | ||||
| 		<p :class="$style.disabledTitle"> | ||||
| 			<i class="ti ti-circle-minus"></i> | ||||
| 			{{ i18n.ts._disabledTimeline.title }} | ||||
| 		</p> | ||||
| 		<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> | ||||
| 	</div> | ||||
| 	<MkNotes v-else ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination"/> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| @@ -14,6 +24,8 @@ import { useStream } from '@/stream'; | ||||
| import * as sound from '@/scripts/sound'; | ||||
| import { $i } from '@/account'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { instance } from '@/instance'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	src: string; | ||||
| @@ -26,15 +38,22 @@ const props = defineProps<{ | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'note'): void; | ||||
| 	(ev: 'queue', count: number): void; | ||||
| 	(ev: 'reload'): void; | ||||
| }>(); | ||||
|  | ||||
| const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); | ||||
| const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); | ||||
|  | ||||
| provide('inChannel', computed(() => props.src === 'channel')); | ||||
|  | ||||
| const tlComponent: InstanceType<typeof MkNotes> = $ref(); | ||||
| let tlComponent: InstanceType<typeof MkNotes> | undefined = $ref(); | ||||
|  | ||||
| const queueSize = computed(() => { | ||||
| 	return tlComponent?.pagingComponent?.queueSize ?? 0; | ||||
| }); | ||||
|  | ||||
| const prepend = note => { | ||||
| 	tlComponent.pagingComponent?.prepend(note); | ||||
| 	tlComponent?.pagingComponent?.prepend(note); | ||||
|  | ||||
| 	emit('note'); | ||||
|  | ||||
| @@ -159,4 +178,48 @@ const timetravel = (date?: Date) => { | ||||
| 	this.$refs.tl.reload(); | ||||
| }; | ||||
| */ | ||||
|  | ||||
| const reload = () => { | ||||
| 	tlComponent?.pagingComponent?.reload(); | ||||
| 	emit('reload'); | ||||
| }; | ||||
|  | ||||
| defineExpose({ | ||||
| 	reload, | ||||
| 	queueSize, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .new { | ||||
| 	position: sticky; | ||||
| 	top: calc(var(--stickyTop, 0px) + 12px); | ||||
| 	z-index: 1000; | ||||
| 	width: 100%; | ||||
| 	margin: calc(-0.675em - 8px) 0; | ||||
|  | ||||
| 	&:first-child { | ||||
| 		margin-top: calc(-0.675em - 8px - var(--margin)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .newButton { | ||||
| 	display: block; | ||||
| 	margin: var(--margin) auto 0 auto; | ||||
| 	padding: 8px 16px; | ||||
| 	border-radius: 32px; | ||||
| } | ||||
|  | ||||
| .disabled { | ||||
| 	text-align: center; | ||||
| } | ||||
|  | ||||
| .disabledTitle { | ||||
| 	margin: 16px; | ||||
| } | ||||
|  | ||||
| .disabledDescription { | ||||
| 	font-size: 90%; | ||||
| 	margin: 16px; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -40,9 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</div> | ||||
| 	<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> | ||||
| 		<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> | ||||
| 		<div :class="$style.tlBody"> | ||||
| 			<MkTimeline src="local"/> | ||||
| 		</div> | ||||
| 		<MkTimeline src="local" :class="$style.tlBody"/> | ||||
| 	</div> | ||||
| 	<div :class="$style.panel"> | ||||
| 		<XActiveUsersChart/> | ||||
|   | ||||
| @@ -16,12 +16,18 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { inject, onBeforeUnmount, provide } from 'vue'; | ||||
| import { Resolved, Router } from '@/nirax'; | ||||
| import { computed, inject, onBeforeUnmount, provide, nextTick } from 'vue'; | ||||
| import { NiraxChangeEvent, Resolved, Router } from '@/nirax'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { getScrollContainer } from '@/scripts/scroll'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	router?: Router; | ||||
|  | ||||
| 	/** | ||||
| 	 * Set any element if scroll position management needed | ||||
| 	 */ | ||||
| 	scrollContainer?: HTMLElement | null; | ||||
| }>(); | ||||
|  | ||||
| const router = props.router ?? inject('router'); | ||||
| @@ -50,17 +56,49 @@ let currentPageComponent = $shallowRef(current.route.component); | ||||
| let currentPageProps = $ref(current.props); | ||||
| let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); | ||||
|  | ||||
| function onChange({ resolved, key: newKey }) { | ||||
| 	const current = resolveNested(resolved); | ||||
| const scrollContainer = computed(() => props.scrollContainer ? (getScrollContainer(props.scrollContainer) ?? document.getElementsByTagName('html')[0]) : undefined); | ||||
|  | ||||
| const scrollPosStore = new Map<string, number>(); | ||||
|  | ||||
| function onChange(ctx: NiraxChangeEvent) { | ||||
| 	// save scroll position | ||||
| 	if (scrollContainer.value) scrollPosStore.set(key, scrollContainer.value.scrollTop); | ||||
|  | ||||
| 	//#region change page | ||||
| 	const current = resolveNested(ctx.resolved); | ||||
| 	if (current == null) return; | ||||
| 	currentPageComponent = current.route.component; | ||||
| 	currentPageProps = current.props; | ||||
| 	key = current.route.path + JSON.stringify(Object.fromEntries(current.props)); | ||||
| 	//#endregion | ||||
|  | ||||
| 	//#region scroll | ||||
| 	nextTick(() => { | ||||
| 		if (!scrollContainer.value) return; | ||||
|  | ||||
| 		const scrollPos = scrollPosStore.get(key) ?? 0; | ||||
| 		scrollContainer.value.scroll({ top: scrollPos, behavior: 'instant' }); | ||||
| 		if (scrollPos !== 0) { | ||||
| 			window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール | ||||
| 				if (!scrollContainer.value) return; | ||||
| 				scrollContainer.value.scroll({ top: scrollPos, behavior: 'instant' }); | ||||
| 			}, 100); | ||||
| 		} | ||||
| 	}); | ||||
| 	//#endregion | ||||
| } | ||||
|  | ||||
| router.addListener('change', onChange); | ||||
|  | ||||
| function onSame() { | ||||
| 	if (!scrollContainer.value) return; | ||||
| 	scrollContainer.value.scroll({ top: 0, behavior: 'smooth' }); | ||||
| } | ||||
|  | ||||
| router.addListener('same', onSame); | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
| 	router.removeListener('change', onChange); | ||||
| 	router.removeListener('same', onSame); | ||||
| }); | ||||
| </script> | ||||
|   | ||||
| @@ -54,24 +54,30 @@ function parsePath(path: string): ParsedPath { | ||||
| 	return res; | ||||
| } | ||||
|  | ||||
| export type NiraxChangeEvent = { | ||||
| 	beforePath: string; | ||||
| 	path: string; | ||||
| 	resolved: Resolved; | ||||
| 	key: string; | ||||
| }; | ||||
|  | ||||
| export type NiraxExportEvent = { | ||||
| 	path: string; | ||||
| 	key: string; | ||||
| }; | ||||
|  | ||||
| export type NiraxPushEvent = { | ||||
| 	beforePath: string; | ||||
| 	path: string; | ||||
| 	route: RouteDef | null; | ||||
| 	props: Map<string, string> | null; | ||||
| 	key: string; | ||||
| }; | ||||
|  | ||||
| export class Router extends EventEmitter<{ | ||||
| 	change: (ctx: { | ||||
| 		beforePath: string; | ||||
| 		path: string; | ||||
| 		resolved: Resolved; | ||||
| 		key: string; | ||||
| 	}) => void; | ||||
| 	replace: (ctx: { | ||||
| 		path: string; | ||||
| 		key: string; | ||||
| 	}) => void; | ||||
| 	push: (ctx: { | ||||
| 		beforePath: string; | ||||
| 		path: string; | ||||
| 		route: RouteDef | null; | ||||
| 		props: Map<string, string> | null; | ||||
| 		key: string; | ||||
| 	}) => void; | ||||
| 	change: (ctx: NiraxChangeEvent) => void; | ||||
| 	replace: (ctx: NiraxExportEvent) => void; | ||||
| 	push: (ctx: NiraxExportEvent) => void; | ||||
| 	same: () => void; | ||||
| }> { | ||||
| 	private routes: RouteDef[]; | ||||
| @@ -276,29 +282,3 @@ export class Router extends EventEmitter<{ | ||||
| 		this.navigate(path, key); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) { | ||||
| 	const scrollPosStore = new Map<string, number>(); | ||||
|  | ||||
| 	onMounted(() => { | ||||
| 		const scrollContainer = getScrollContainer(); | ||||
|  | ||||
| 		scrollContainer.addEventListener('scroll', () => { | ||||
| 			scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); | ||||
| 		}, { passive: true }); | ||||
|  | ||||
| 		router.addListener('change', ctx => { | ||||
| 			const scrollPos = scrollPosStore.get(ctx.key) ?? 0; | ||||
| 			scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); | ||||
| 			if (scrollPos !== 0) { | ||||
| 				window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール | ||||
| 					scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); | ||||
| 				}, 100); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		router.addListener('same', () => { | ||||
| 			scrollContainer.scroll({ top: 0, behavior: 'smooth' }); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|   | ||||
| @@ -8,16 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<div ref="rootEl" v-hotkey.global="keymap"> | ||||
| 			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> | ||||
| 			<div :class="$style.tl"> | ||||
| 				<MkTimeline | ||||
| 					ref="tlEl" :key="antennaId" | ||||
| 					src="antenna" | ||||
| 					:antenna="antennaId" | ||||
| 					:sound="true" | ||||
| 					@queue="queueUpdated" | ||||
| 				/> | ||||
| 			</div> | ||||
| 			<MkTimeline | ||||
| 				ref="tlEl" :key="antennaId" | ||||
| 				src="antenna" | ||||
| 				:antenna="antennaId" | ||||
| 				:sound="true" | ||||
| 				:class="$style.tl" | ||||
| 			/> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| @@ -26,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import { scroll } from '@/scripts/scroll'; | ||||
| import * as os from '@/os'; | ||||
| import { useRouter } from '@/router'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| @@ -39,19 +35,14 @@ const props = defineProps<{ | ||||
| }>(); | ||||
|  | ||||
| let antenna = $ref(null); | ||||
| let queue = $ref(0); | ||||
| let rootEl = $shallowRef<HTMLElement>(); | ||||
| let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| const keymap = $computed(() => ({ | ||||
| 	't': focus, | ||||
| })); | ||||
|  | ||||
| function queueUpdated(q) { | ||||
| 	queue = q; | ||||
| } | ||||
|  | ||||
| function top() { | ||||
| 	scroll(rootEl, { top: 0 }); | ||||
| 	tlEl?.reload(); | ||||
| } | ||||
|  | ||||
| async function timetravel() { | ||||
| @@ -96,25 +87,6 @@ definePageMetadata(computed(() => antenna ? { | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .new { | ||||
| 	position: sticky; | ||||
| 	top: calc(var(--stickyTop, 0px) + 16px); | ||||
| 	z-index: 1000; | ||||
| 	width: 100%; | ||||
| 	margin: calc(-0.675em - 8px) 0; | ||||
|  | ||||
| 	&:first-child { | ||||
| 		margin-top: calc(-0.675em - 8px - var(--margin)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .newButton { | ||||
| 	display: block; | ||||
| 	margin: var(--margin) auto 0 auto; | ||||
| 	padding: 8px 16px; | ||||
| 	border-radius: 32px; | ||||
| } | ||||
|  | ||||
| .tl { | ||||
| 	background: var(--bg); | ||||
| 	border-radius: var(--radius); | ||||
|   | ||||
| @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> | ||||
| 			<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> | ||||
|  | ||||
| 			<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/> | ||||
| 			<MkTimeline :key="channelId" src="channel" :channel="channelId" /> | ||||
| 		</div> | ||||
| 		<div v-else-if="tab === 'featured'"> | ||||
| 			<MkNotes :pagination="featuredPagination"/> | ||||
|   | ||||
| @@ -26,10 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</MkRadios> | ||||
|  | ||||
| 	<FormSection> | ||||
| 		<div class="_gaps_s"> | ||||
| 			<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> | ||||
| 			<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> | ||||
| 			<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch> | ||||
| 		<div class="_gaps_m"> | ||||
| 			<div class="_gaps_s"> | ||||
| 				<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> | ||||
| 				<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> | ||||
| 				<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch> | ||||
| 			</div> | ||||
|  | ||||
| 			<MkSelect v-model="timelineBackTopBehavior" :disabled="isWebKit()" :readonly="isWebKit()"> | ||||
| 				<template #label>{{ i18n.ts.timelineBackTopBehavior }}</template> | ||||
| 				<option value="newest">{{ i18n.ts._timelineBackTopBehavior.newest }}</option> | ||||
| 				<option value="next">{{ i18n.ts._timelineBackTopBehavior.next }}</option> | ||||
| 			</MkSelect> | ||||
| 		</div> | ||||
| 	</FormSection> | ||||
|  | ||||
| @@ -193,6 +201,8 @@ import { unisonReload } from '@/scripts/unison-reload'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { miLocalStorage } from '@/local-storage'; | ||||
| import { isWebKit } from '@/scripts/useragent'; | ||||
| import { testNotification } from '@/scripts/test-notification'; | ||||
| import { globalEvents } from '@/events'; | ||||
| import { claimAchievement } from '@/scripts/achievements'; | ||||
|  | ||||
| @@ -241,6 +251,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 timelineBackTopBehavior = computed(defaultStore.makeGetterSetter('timelineBackTopBehavior')); | ||||
|  | ||||
| watch(lang, () => { | ||||
| 	miLocalStorage.setItem('lang', lang.value as string); | ||||
|   | ||||
| @@ -92,6 +92,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ | ||||
| 	'numberOfPageCache', | ||||
| 	'aiChanMode', | ||||
| 	'mediaListWithOneImageAppearance', | ||||
| 	'timelineBackTopBehavior', | ||||
| ]; | ||||
| const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ | ||||
| 	'lightTheme', | ||||
|   | ||||
| @@ -11,16 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			<XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> | ||||
| 			<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> | ||||
|  | ||||
| 			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> | ||||
| 			<div :class="$style.tl"> | ||||
| 				<MkTimeline | ||||
| 					ref="tlComponent" | ||||
| 					:key="src" | ||||
| 					:src="src" | ||||
| 					:sound="true" | ||||
| 					@queue="queueUpdated" | ||||
| 				/> | ||||
| 			</div> | ||||
| 			<MkTimeline | ||||
| 				ref="tlComponent" | ||||
| 				:key="src" | ||||
| 				:src="src" | ||||
| 				:sound="true" | ||||
| 				:class="$style.tl" | ||||
| 			/> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| @@ -31,7 +28,6 @@ import { defineAsyncComponent, computed, watch, provide } from 'vue'; | ||||
| import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import MkPostForm from '@/components/MkPostForm.vue'; | ||||
| import { scroll } from '@/scripts/scroll'; | ||||
| import * as os from '@/os'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { i18n } from '@/i18n'; | ||||
| @@ -54,18 +50,11 @@ const keymap = { | ||||
| const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| const rootEl = $shallowRef<HTMLElement>(); | ||||
|  | ||||
| let queue = $ref(0); | ||||
| let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); | ||||
| const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); | ||||
|  | ||||
| watch ($$(src), () => queue = 0); | ||||
|  | ||||
| function queueUpdated(q: number): void { | ||||
| 	queue = q; | ||||
| } | ||||
|  | ||||
| function top(): void { | ||||
| 	if (rootEl) scroll(rootEl, { top: 0 }); | ||||
| 	tlComponent?.reload(); | ||||
| } | ||||
|  | ||||
| async function chooseList(ev: MouseEvent): Promise<void> { | ||||
| @@ -184,25 +173,6 @@ definePageMetadata(computed(() => ({ | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .new { | ||||
| 	position: sticky; | ||||
| 	top: calc(var(--stickyTop, 0px) + 16px); | ||||
| 	z-index: 1000; | ||||
| 	width: 100%; | ||||
| 	margin: calc(-0.675em - 8px) 0; | ||||
|  | ||||
| 	&:first-child { | ||||
| 		margin-top: calc(-0.675em - 8px - var(--margin)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .newButton { | ||||
| 	display: block; | ||||
| 	margin: var(--margin) auto 0 auto; | ||||
| 	padding: 8px 16px; | ||||
| 	border-radius: 32px; | ||||
| } | ||||
|  | ||||
| .postForm { | ||||
| 	border-radius: var(--radius); | ||||
| } | ||||
|   | ||||
| @@ -8,16 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<div ref="rootEl"> | ||||
| 			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> | ||||
| 			<div :class="$style.tl"> | ||||
| 				<MkTimeline | ||||
| 					ref="tlEl" :key="listId" | ||||
| 					src="list" | ||||
| 					:list="listId" | ||||
| 					:sound="true" | ||||
| 					@queue="queueUpdated" | ||||
| 				/> | ||||
| 			</div> | ||||
| 			<MkTimeline | ||||
| 				ref="tlEl" :key="listId" | ||||
| 				src="list" | ||||
| 				:list="listId" | ||||
| 				:sound="true" | ||||
| 				:class="$style.tl" | ||||
| 			/> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| @@ -26,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import { scroll } from '@/scripts/scroll'; | ||||
| import * as os from '@/os'; | ||||
| import { useRouter } from '@/router'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| @@ -39,7 +35,6 @@ const props = defineProps<{ | ||||
| }>(); | ||||
|  | ||||
| let list = $ref(null); | ||||
| let queue = $ref(0); | ||||
| let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
| let rootEl = $shallowRef<HTMLElement>(); | ||||
|  | ||||
| @@ -49,12 +44,8 @@ watch(() => props.listId, async () => { | ||||
| 	}); | ||||
| }, { immediate: true }); | ||||
|  | ||||
| function queueUpdated(q) { | ||||
| 	queue = q; | ||||
| } | ||||
|  | ||||
| function top() { | ||||
| 	scroll(rootEl, { top: 0 }); | ||||
| 	tlEl?.reload(); | ||||
| } | ||||
|  | ||||
| function settings() { | ||||
| @@ -89,24 +80,6 @@ definePageMetadata(computed(() => list ? { | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .new { | ||||
| 	position: sticky; | ||||
| 	top: calc(var(--stickyTop, 0px) + 16px); | ||||
| 	z-index: 1000; | ||||
| 	width: 100%; | ||||
| 	margin: calc(-0.675em - 8px) 0; | ||||
|  | ||||
| 	&:first-child { | ||||
| 		margin-top: calc(-0.675em - 8px - var(--margin)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .newButton { | ||||
| 	display: block; | ||||
| 	margin: var(--margin) auto 0 auto; | ||||
| 	padding: 8px 16px; | ||||
| 	border-radius: 32px; | ||||
| } | ||||
|  | ||||
| .tl { | ||||
| 	background: var(--bg); | ||||
|   | ||||
| @@ -30,7 +30,7 @@ export function getScrollPosition(el: HTMLElement | null): number { | ||||
|  | ||||
| export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) { | ||||
| 	// とりあえず評価してみる | ||||
| 	if (el.isConnected && isTopVisible(el)) { | ||||
| 	if (el.isConnected && isTopVisible(el, tolerance)) { | ||||
| 		cb(); | ||||
| 		if (once) return null; | ||||
| 	} | ||||
| @@ -75,12 +75,29 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 | ||||
| 	return removeListener; | ||||
| } | ||||
|  | ||||
| export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) { | ||||
| 	const container = getScrollContainer(el); | ||||
| 	if (container == null) { | ||||
| /** | ||||
|  * コンテナを指定してスクロールする | ||||
|  * @param el Container element | ||||
|  * @param options ScrollToOptions | ||||
|  */ | ||||
| export function scroll(el: HTMLElement | null, options: ScrollToOptions | undefined) { | ||||
| 	if (el == null) { | ||||
| 		window.scroll(options); | ||||
| 	} else { | ||||
| 		container.scroll(options); | ||||
| 		el.scroll(options); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * コンテナを指定してscrollByする | ||||
|  * @param el Container element | ||||
|  * @param options ScrollToOptions | ||||
|  */ | ||||
| export function scrollBy(el: HTMLElement | null, options: ScrollToOptions | undefined) { | ||||
| 	if (el == null) { | ||||
| 		window.scrollBy(options); | ||||
| 	} else { | ||||
| 		el.scrollBy(options); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -89,8 +106,8 @@ export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) { | ||||
|  * @param el Scroll container element | ||||
|  * @param options Scroll options | ||||
|  */ | ||||
| export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) { | ||||
| 	scroll(el, { top: 0, ...options }); | ||||
| export function scrollToTop(el: HTMLElement | null, options: { behavior?: ScrollBehavior; } = {}) { | ||||
| 	scroll(getScrollContainer(el), { top: 0, ...options }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
							
								
								
									
										3
									
								
								packages/frontend/src/scripts/useragent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/frontend/src/scripts/useragent.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| import { UAParser } from 'ua-parser-js'; | ||||
| const ua = new UAParser(navigator.userAgent); | ||||
| export const isWebKit = () => ua.getEngine().name === 'WebKit'; | ||||
| @@ -6,6 +6,7 @@ | ||||
| import { markRaw, ref } from 'vue'; | ||||
| import misskey from 'misskey-js'; | ||||
| import { Storage } from './pizzax'; | ||||
| import { isWebKit } from './scripts/useragent'; | ||||
|  | ||||
| interface PostFormAction { | ||||
| 	title: string, | ||||
| @@ -352,6 +353,10 @@ export const defaultStore = markRaw(new Storage('base', { | ||||
| 		where: 'device', | ||||
| 		default: {} as Record<string, Record<string, string[]>>, | ||||
| 	}, | ||||
| 	timelineBackTopBehavior: { | ||||
| 		where: 'device', | ||||
| 		default: (isWebKit() ? 'newest' : 'next') as 'newest' | 'next', | ||||
| 	}, | ||||
| })); | ||||
|  | ||||
| // TODO: 他のタブと永続化されたstateを同期 | ||||
|   | ||||
| @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</template> | ||||
|  | ||||
| 	<div ref="contents"> | ||||
| 		<RouterView @contextmenu.stop="onContextmenu"/> | ||||
| 		<RouterView :scrollContainer="contents" @contextmenu.stop="onContextmenu"/> | ||||
| 	</div> | ||||
| </XColumn> | ||||
| </template> | ||||
| @@ -26,8 +26,6 @@ import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { mainRouter } from '@/router'; | ||||
| import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | ||||
| import { useScrollPositionManager } from '@/nirax'; | ||||
| import { getScrollContainer } from '@/scripts/scroll'; | ||||
|  | ||||
| defineProps<{ | ||||
| 	column: Column; | ||||
| @@ -71,6 +69,4 @@ function onContextmenu(ev: MouseEvent) { | ||||
| 		}, | ||||
| 	}], ev); | ||||
| } | ||||
|  | ||||
| useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter); | ||||
| </script> | ||||
|   | ||||
| @@ -13,14 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 		<span style="margin-left: 8px;">{{ column.name }}</span> | ||||
| 	</template> | ||||
|  | ||||
| 	<div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> | ||||
| 		<p :class="$style.disabledTitle"> | ||||
| 			<i class="ti ti-circle-minus"></i> | ||||
| 			{{ i18n.ts._disabledTimeline.title }} | ||||
| 		</p> | ||||
| 		<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> | ||||
| 	</div> | ||||
| 	<MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/> | ||||
| 	<MkTimeline v-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/> | ||||
| </XColumn> | ||||
| </template> | ||||
|  | ||||
| @@ -30,27 +23,16 @@ import XColumn from './column.vue'; | ||||
| import { removeColumn, updateColumn, Column } from './deck-store'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { $i } from '@/account'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { instance } from '@/instance'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
| 	isStacked: boolean; | ||||
| }>(); | ||||
|  | ||||
| let disabled = $ref(false); | ||||
|  | ||||
| const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); | ||||
| const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); | ||||
|  | ||||
| onMounted(() => { | ||||
| 	if (props.column.tl == null) { | ||||
| 		setType(); | ||||
| 	} else if ($i) { | ||||
| 		disabled = ( | ||||
| 			(!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) || | ||||
| 			(!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl))); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| @@ -84,17 +66,3 @@ const menu = [{ | ||||
| 	action: setType, | ||||
| }]; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .disabled { | ||||
| 	text-align: center; | ||||
| } | ||||
|  | ||||
| .disabledTitle { | ||||
| 	margin: 16px; | ||||
| } | ||||
|  | ||||
| .disabledDescription { | ||||
| 	font-size: 90%; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 				<XStatusBars :class="$style.statusbars"/> | ||||
| 			</div> | ||||
| 		</template> | ||||
| 		<RouterView/> | ||||
| 		<RouterView :scrollContainer="contents?.rootEl"/> | ||||
| 		<div :class="$style.spacer"></div> | ||||
| 	</MkStickyContainer> | ||||
|  | ||||
| @@ -105,7 +105,6 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | ||||
| import { deviceKind } from '@/scripts/device-kind'; | ||||
| import { miLocalStorage } from '@/local-storage'; | ||||
| import { CURRENT_STICKY_BOTTOM } from '@/const'; | ||||
| import { useScrollPositionManager } from '@/nirax'; | ||||
|  | ||||
| const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); | ||||
| const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); | ||||
| @@ -227,8 +226,6 @@ watch($$(navFooter), () => { | ||||
| }, { | ||||
| 	immediate: true, | ||||
| }); | ||||
|  | ||||
| useScrollPositionManager(() => contents.value.rootEl, mainRouter); | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
|   | ||||
| @@ -20,33 +20,20 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 		</button> | ||||
| 	</template> | ||||
|  | ||||
| 	<div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> | ||||
| 		<p :class="$style.disabledTitle"> | ||||
| 			<i class="ti ti-minus"></i> | ||||
| 			{{ i18n.ts._disabledTimeline.title }} | ||||
| 		</p> | ||||
| 		<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p> | ||||
| 	</div> | ||||
| 	<div v-else> | ||||
| 		<MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> | ||||
| 	</div> | ||||
| 	<MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/> | ||||
| </MkContainer> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref } from 'vue'; | ||||
| import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; | ||||
| import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; | ||||
| import { GetFormResultType } from '@/scripts/form'; | ||||
| import * as os from '@/os'; | ||||
| import MkContainer from '@/components/MkContainer.vue'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { $i } from '@/account'; | ||||
| import { instance } from '@/instance'; | ||||
|  | ||||
| const name = 'timeline'; | ||||
| const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); | ||||
| const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); | ||||
|  | ||||
| const widgetPropsDef = { | ||||
| 	showHeader: { | ||||
| @@ -141,17 +128,3 @@ defineExpose<WidgetComponentExpose>({ | ||||
| 	id: props.widget ? props.widget.id : null, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .disabled { | ||||
| 	text-align: center; | ||||
| } | ||||
|  | ||||
| .disabledTitle { | ||||
| 	margin: 16px; | ||||
| } | ||||
|  | ||||
| .disabledDescription { | ||||
| 	font-size: 90%; | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										8
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -799,6 +799,9 @@ importers: | ||||
|       typescript: | ||||
|         specifier: 5.2.2 | ||||
|         version: 5.2.2 | ||||
|       ua-parser-js: | ||||
|         specifier: 2.0.0-alpha.2 | ||||
|         version: 2.0.0-alpha.2 | ||||
|       uuid: | ||||
|         specifier: 9.0.1 | ||||
|         version: 9.0.1 | ||||
| @@ -11846,6 +11849,7 @@ packages: | ||||
|   /form-data@3.0.1: | ||||
|     resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} | ||||
|     engines: {node: '>= 6'} | ||||
|     requiresBuild: true | ||||
|     dependencies: | ||||
|       asynckit: 0.4.0 | ||||
|       combined-stream: 1.0.8 | ||||
| @@ -18818,6 +18822,10 @@ packages: | ||||
|     engines: {node: '>=14.17'} | ||||
|     hasBin: true | ||||
|  | ||||
|   /ua-parser-js@2.0.0-alpha.2: | ||||
|     resolution: {integrity: sha512-Vz+BJN/EFC1OaUv0eu5kPyX7HEZIO7Dv29jIK7rMuKjUB1qqq+Is/XIpu5iV5XDvoNl62dM7ay8DtzYjBDI0WA==} | ||||
|     dev: false | ||||
|  | ||||
|   /ufo@1.1.2: | ||||
|     resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} | ||||
|     dev: true | ||||
|   | ||||
		Reference in New Issue
	
	Block a user