Compare commits
	
		
			126 Commits
		
	
	
		
			2024.10.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; |     "deleteAll": string; | ||||||
|     "showFixedPostForm": string; |     "showFixedPostForm": string; | ||||||
|     "showFixedPostFormInChannel": string; |     "showFixedPostFormInChannel": string; | ||||||
|     "newNoteRecived": string; |     "goToTheHeadOfTimeline": string; | ||||||
|     "sounds": string; |     "sounds": string; | ||||||
|     "sound": string; |     "sound": string; | ||||||
|     "listen": string; |     "listen": string; | ||||||
| @@ -1103,6 +1103,7 @@ export interface Locale { | |||||||
|     "doYouAgree": string; |     "doYouAgree": string; | ||||||
|     "beSureToReadThisAsItIsImportant": string; |     "beSureToReadThisAsItIsImportant": string; | ||||||
|     "iHaveReadXCarefullyAndAgree": string; |     "iHaveReadXCarefullyAndAgree": string; | ||||||
|  |     "timelineBackTopBehavior": string; | ||||||
|     "dialog": string; |     "dialog": string; | ||||||
|     "icon": string; |     "icon": string; | ||||||
|     "forYou": string; |     "forYou": string; | ||||||
| @@ -1672,6 +1673,10 @@ export interface Locale { | |||||||
|         "dialog": string; |         "dialog": string; | ||||||
|         "quiet": string; |         "quiet": string; | ||||||
|     }; |     }; | ||||||
|  |     "_timelineBackTopBehavior": { | ||||||
|  |         "newest": string; | ||||||
|  |         "next": string; | ||||||
|  |     }; | ||||||
|     "_channel": { |     "_channel": { | ||||||
|         "create": string; |         "create": string; | ||||||
|         "edit": string; |         "edit": string; | ||||||
|   | |||||||
| @@ -530,7 +530,7 @@ serverLogs: "サーバーログ" | |||||||
| deleteAll: "全て削除" | deleteAll: "全て削除" | ||||||
| showFixedPostForm: "タイムライン上部に投稿フォームを表示する" | showFixedPostForm: "タイムライン上部に投稿フォームを表示する" | ||||||
| showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" | showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" | ||||||
| newNoteRecived: "新しいノートがあります" | goToTheHeadOfTimeline: "最新のノートに移動" | ||||||
| sounds: "サウンド" | sounds: "サウンド" | ||||||
| sound: "サウンド" | sound: "サウンド" | ||||||
| listen: "聴く" | listen: "聴く" | ||||||
| @@ -1100,6 +1100,7 @@ expired: "期限切れ" | |||||||
| doYouAgree: "同意しますか?" | doYouAgree: "同意しますか?" | ||||||
| beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。" | beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。" | ||||||
| iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。" | iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。" | ||||||
|  | timelineBackTopBehavior: "タイムラインのスクロールが先頭に戻った時の挙動" | ||||||
| dialog: "ダイアログ" | dialog: "ダイアログ" | ||||||
| icon: "アイコン" | icon: "アイコン" | ||||||
| forYou: "あなたへ" | forYou: "あなたへ" | ||||||
| @@ -1589,6 +1590,10 @@ _serverDisconnectedBehavior: | |||||||
|   dialog: "ダイアログで警告" |   dialog: "ダイアログで警告" | ||||||
|   quiet: "控えめに警告" |   quiet: "控えめに警告" | ||||||
|  |  | ||||||
|  | _timelineBackTopBehavior: | ||||||
|  |   newest: "最新の投稿を表示" | ||||||
|  |   next: "次の投稿を遡る" | ||||||
|  |  | ||||||
| _channel: | _channel: | ||||||
|   create: "チャンネルを作成" |   create: "チャンネルを作成" | ||||||
|   edit: "チャンネルを編集" |   edit: "チャンネルを編集" | ||||||
|   | |||||||
| @@ -68,6 +68,7 @@ | |||||||
| 		"tsconfig-paths": "4.2.0", | 		"tsconfig-paths": "4.2.0", | ||||||
| 		"twemoji-parser": "14.0.0", | 		"twemoji-parser": "14.0.0", | ||||||
| 		"typescript": "5.2.2", | 		"typescript": "5.2.2", | ||||||
|  | 		"ua-parser-js": "2.0.0-alpha.2", | ||||||
| 		"uuid": "9.0.1", | 		"uuid": "9.0.1", | ||||||
| 		"vanilla-tilt": "1.8.1", | 		"vanilla-tilt": "1.8.1", | ||||||
| 		"vite": "4.4.9", | 		"vite": "4.4.9", | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| --> | --> | ||||||
|  |  | ||||||
| <script lang="ts"> | <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 MkAd from '@/components/global/MkAd.vue'; | ||||||
| import { isDebuggerEnabled, stackTraceInstances } from '@/debug'; | import { isDebuggerEnabled, stackTraceInstances } from '@/debug'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| @@ -38,6 +38,11 @@ export default defineComponent({ | |||||||
| 			required: false, | 			required: false, | ||||||
| 			default: false, | 			default: false, | ||||||
| 		}, | 		}, | ||||||
|  | 		denyMoveTransition: { | ||||||
|  | 			type: Boolean, | ||||||
|  | 			required: false, | ||||||
|  | 			default: false, | ||||||
|  | 		}, | ||||||
| 	}, | 	}, | ||||||
|  |  | ||||||
| 	setup(props, { slots, expose }) { | 	setup(props, { slots, expose }) { | ||||||
| @@ -135,6 +140,7 @@ export default defineComponent({ | |||||||
| 					[$style['reversed']]: props.reversed, | 					[$style['reversed']]: props.reversed, | ||||||
| 					[$style['direction-down']]: props.direction === 'down', | 					[$style['direction-down']]: props.direction === 'down', | ||||||
| 					[$style['direction-up']]: props.direction === 'up', | 					[$style['direction-up']]: props.direction === 'up', | ||||||
|  | 					'deny-move-transition': props.denyMoveTransition, | ||||||
| 				}, | 				}, | ||||||
| 				...(defaultStore.state.animation ? { | 				...(defaultStore.state.animation ? { | ||||||
| 					name: 'list', | 					name: 'list', | ||||||
| @@ -153,15 +159,11 @@ export default defineComponent({ | |||||||
| 	container-type: inline-size; | 	container-type: inline-size; | ||||||
|  |  | ||||||
| 	&:global { | 	&:global { | ||||||
| 	> .list-move { | 	&:not(.deny-move-transition) > .list-move { | ||||||
| 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); | 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	&.deny-move-transition > .list-move { | 	&:not(.deny-move-transition) > .list-enter-active { | ||||||
| 		transition: none !important; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	> .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); | 		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> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
| 	<template #default="{ items: notes }"> | 	<template #default="{ items: notes, denyMoveTransition }"> | ||||||
| 		<div :class="[$style.root, { [$style.noGap]: noGap }]"> | 		<div :class="[$style.root, { [$style.noGap]: noGap }]"> | ||||||
| 			<MkDateSeparatedList | 			<MkDateSeparatedList | ||||||
| 				ref="notes" | 				ref="notes" | ||||||
| @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 				:noGap="noGap" | 				:noGap="noGap" | ||||||
| 				:ad="true" | 				:ad="true" | ||||||
| 				:class="$style.notes" | 				:class="$style.notes" | ||||||
|  | 				:denyMoveTransition="denyMoveTransition" | ||||||
| 			> | 			> | ||||||
| 				<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> | 				<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> | ||||||
| 			</MkDateSeparatedList> | 			</MkDateSeparatedList> | ||||||
|   | |||||||
| @@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 		</div> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
| 	<template #default="{ items: notifications }"> | 	<template #default="{ items: notifications, denyMoveTransition }"> | ||||||
| 		<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> | 		<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition"> | ||||||
| 			<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | 			<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"/> | 			<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/> | ||||||
| 		</MkDateSeparatedList> | 		</MkDateSeparatedList> | ||||||
| 	</template> | 	</template> | ||||||
| @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts" setup> | <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 MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||||
| import XNotification from '@/components/MkNotification.vue'; | import XNotification from '@/components/MkNotification.vue'; | ||||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||||
| @@ -55,10 +55,16 @@ const onNotification = (notification) => { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if (!isMuted) { | 	if (!isMuted) { | ||||||
| 		pagingComponent.value.prepend(notification); | 		pagingComponent.value?.prepend(notification); | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | watch(() => pagingComponent.value?.backed, (backed) => { | ||||||
|  | 	if (backed === false) { | ||||||
|  | 		useStream().send('readNotification'); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
| let connection; | let connection; | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
| 	<div ref="contents" :class="$style.root" style="container-type: inline-size;"> | 	<div ref="contents" :class="$style.root" style="container-type: inline-size;"> | ||||||
| 		<RouterView :key="reloadCount" :router="router"/> | 		<RouterView :key="reloadCount" :router="router" :scrollContainer="contents"/> | ||||||
| 	</div> | 	</div> | ||||||
| </MkWindow> | </MkWindow> | ||||||
| </template> | </template> | ||||||
| @@ -37,12 +37,11 @@ import copyToClipboard from '@/scripts/copy-to-clipboard'; | |||||||
| import { url } from '@/config'; | import { url } from '@/config'; | ||||||
| import { mainRouter, routes, page } from '@/router'; | import { mainRouter, routes, page } from '@/router'; | ||||||
| import { $i } from '@/account'; | import { $i } from '@/account'; | ||||||
| import { Router, useScrollPositionManager } from '@/nirax'; | import { Router } from '@/nirax'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | ||||||
| import { openingWindowsCount } from '@/os'; | import { openingWindowsCount } from '@/os'; | ||||||
| import { claimAchievement } from '@/scripts/achievements'; | import { claimAchievement } from '@/scripts/achievements'; | ||||||
| import { getScrollContainer } from '@/scripts/scroll'; |  | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
| 	initialPath: string; | 	initialPath: string; | ||||||
| @@ -146,8 +145,6 @@ function popout() { | |||||||
| 	windowEl.close(); | 	windowEl.close(); | ||||||
| } | } | ||||||
|  |  | ||||||
| useScrollPositionManager(() => getScrollContainer(contents.value), router); |  | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
| 	openingWindowsCount.value++; | 	openingWindowsCount.value++; | ||||||
| 	if (openingWindowsCount.value >= 3) { | 	if (openingWindowsCount.value >= 3) { | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| > | > | ||||||
| 	<MkLoading v-if="fetching"/> | 	<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"> | 	<div v-else-if="empty" key="_empty_" class="empty"> | ||||||
| 		<slot name="empty"> | 		<slot name="empty"> | ||||||
| @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 			</MkButton> | 			</MkButton> | ||||||
| 			<MkLoading v-else class="loading"/> | 			<MkLoading v-else class="loading"/> | ||||||
| 		</div> | 		</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"> | 		<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"> | 			<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 }} | 				{{ 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 { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'; | ||||||
| import * as Misskey from 'misskey-js'; | import * as Misskey from 'misskey-js'; | ||||||
| import * as os from '@/os'; | 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 { useDocumentVisibility } from '@/scripts/use-document-visibility'; | ||||||
| import MkButton from '@/components/MkButton.vue'; | import MkButton from '@/components/MkButton.vue'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| import { MisskeyEntity } from '@/types/date-separated-list'; | import { MisskeyEntity } from '@/types/date-separated-list'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
|  | import { isWebKit } from '@/scripts/useragent'; | ||||||
|  |  | ||||||
| const SECOND_FETCH_LIMIT = 30; | const SECOND_FETCH_LIMIT = 30; | ||||||
| const TOLERANCE = 16; | const TOLERANCE = 6; | ||||||
| const APPEAR_MINIMUM_INTERVAL = 600; | const APPEAR_MINIMUM_INTERVAL = 600; | ||||||
|  |  | ||||||
| export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { | export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { | ||||||
| 	endpoint: E; | 	endpoint: E; | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * 一度にAPIへ取得する件数 | ||||||
|  | 	 */ | ||||||
| 	limit: number; | 	limit: number; | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * タイムラインに表示する最大件数 | ||||||
|  | 	 */ | ||||||
|  | 	displayLimit?: number; | ||||||
|  |  | ||||||
| 	params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>; | 	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 { | function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap { | ||||||
| 	return new Map([...map, ...arrayToEntries(entities)]); | 	return new Map([...map, ...arrayToEntries(entities)]); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const timelineBackTopBehavior = computed(() => isWebKit() ? 'newest' : defaultStore.reactiveState.timelineBackTopBehavior.value); | ||||||
| </script> | </script> | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { infoImageUrl } from '@/instance'; | import { infoImageUrl } from '@/instance'; | ||||||
| @@ -94,19 +107,19 @@ import { infoImageUrl } from '@/instance'; | |||||||
| const props = withDefaults(defineProps<{ | const props = withDefaults(defineProps<{ | ||||||
| 	pagination: Paging; | 	pagination: Paging; | ||||||
| 	disableAutoLoad?: boolean; | 	disableAutoLoad?: boolean; | ||||||
| 	displayLimit?: number; |  | ||||||
| }>(), { | }>(), { | ||||||
| 	displayLimit: 20, |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits<{ |  | ||||||
| 	(ev: 'queue', count: number): void; |  | ||||||
| }>(); |  | ||||||
|  |  | ||||||
| let rootEl = $shallowRef<HTMLElement>(); | let rootEl = $shallowRef<HTMLElement>(); | ||||||
|  |  | ||||||
| // 遡り中かどうか | /** | ||||||
|  |  * スクロールが先頭にある場合はfalse | ||||||
|  |  * スクロールが先頭にない場合にtrue | ||||||
|  |  */ | ||||||
|  | // 先頭にいるか(prependでキューに追加するかどうかの判定に使う) | ||||||
| let backed = $ref(false); | let backed = $ref(false); | ||||||
|  | // true→falseの変更でexecuteQueueする | ||||||
|  | let weakBacked = $ref(false); | ||||||
|  |  | ||||||
| let scrollRemove = $ref<(() => void) | null>(null); | let scrollRemove = $ref<(() => void) | null>(null); | ||||||
|  |  | ||||||
| @@ -115,12 +128,14 @@ let scrollRemove = $ref<(() => void) | null>(null); | |||||||
|  * 最新が0番目 |  * 最新が0番目 | ||||||
|  */ |  */ | ||||||
| const items = ref<MisskeyEntityMap>(new Map()); | const items = ref<MisskeyEntityMap>(new Map()); | ||||||
|  | const providingItems = computed(() => Array.from(items.value.values())); | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * タブが非アクティブなどの場合に更新を貯めておく |  * タブが非アクティブなどの場合に更新を貯めておく | ||||||
|  * 最新が0番目 |  * 最新が最後(パフォーマンス上の理由でitemsと逆にした) | ||||||
|  */ |  */ | ||||||
| const queue = ref<MisskeyEntityMap>(new Map()); | const queue = ref<MisskeyEntityMap>(new Map()); | ||||||
|  | const queueSize = computed(() => queue.value.size); | ||||||
|  |  | ||||||
| const offset = ref(0); | const offset = ref(0); | ||||||
|  |  | ||||||
| @@ -129,69 +144,153 @@ const offset = ref(0); | |||||||
|  */ |  */ | ||||||
| const fetching = ref(true); | const fetching = ref(true); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * onActivatedでtrue, onDeactivatedでfalseになる | ||||||
|  |  */ | ||||||
|  | const active = ref(true); | ||||||
|  |  | ||||||
| const moreFetching = ref(false); | const moreFetching = ref(false); | ||||||
| const more = ref(false); | const more = ref(false); | ||||||
| const preventAppearFetchMore = ref(false); | const preventAppearFetchMore = ref(false); | ||||||
| const preventAppearFetchMoreTimer = ref<number | null>(null); | const preventAppearFetchMoreTimer = ref<number | null>(null); | ||||||
| const isBackTop = ref(false); |  | ||||||
| const empty = computed(() => items.value.size === 0); | const empty = computed(() => items.value.size === 0); | ||||||
| const error = ref(false); | const error = ref(false); | ||||||
| const { | const { | ||||||
| 	enableInfiniteScroll, | 	enableInfiniteScroll, | ||||||
| } = defaultStore.reactiveState; | } = defaultStore.reactiveState; | ||||||
|  |  | ||||||
|  | const displayLimit = computed(() => props.pagination.displayLimit ?? props.pagination.limit * 2); | ||||||
|  |  | ||||||
| const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); | 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(); | const visibility = useDocumentVisibility(); | ||||||
|  |  | ||||||
| let isPausingUpdate = false; | const isPausingUpdateByExecutingQueue = ref(false); | ||||||
| let timerForSetPause: number | null = null; | const denyMoveTransition = ref(false); | ||||||
| const BACKGROUND_PAUSE_WAIT_SEC = 10; |  | ||||||
|  |  | ||||||
| // 先頭が表示されているかどうかを検出 | //#region scrolling | ||||||
| // https://qiita.com/mkataigi/items/0154aefd2223ce23398e | 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>(); | let scrollObserver = $ref<IntersectionObserver>(); | ||||||
|  |  | ||||||
| watch([() => props.pagination.reversed, $$(scrollableElement)], () => { | watch([() => props.pagination.reversed, $$(scrollableElement)], () => { | ||||||
| 	if (scrollObserver) scrollObserver.disconnect(); | 	if (scrollObserver) scrollObserver.disconnect(); | ||||||
|  |  | ||||||
| 	scrollObserver = new IntersectionObserver(entries => { | 	scrollObserver = new IntersectionObserver(entries => { | ||||||
| 		backed = entries[0].isIntersecting; | 		if (!active.value) return; // activeでない時は触らない | ||||||
|  | 		weakBacked = entries[0].intersectionRatio >= 0.1; | ||||||
| 	}, { | 	}, { | ||||||
| 		root: scrollableElement, | 		root: scrollableElement, | ||||||
| 		rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', | 		rootMargin: props.pagination.reversed ? '-100% 0px 1000% 0px' : '1000% 0px -100% 0px', | ||||||
| 		threshold: 0.01, | 		threshold: [0.01, 0.05, 0.1, 0.12, 0.15], | ||||||
| 	}); | 	}); | ||||||
| }, { immediate: true }); | }, { immediate: true }); | ||||||
|  |  | ||||||
| watch($$(rootEl), () => { | watch([$$(rootEl), $$(scrollObserver)], () => { | ||||||
| 	scrollObserver?.disconnect(); | 	scrollObserver?.disconnect(); | ||||||
| 	nextTick(() => { | 	if (rootEl) scrollObserver?.observe(rootEl); | ||||||
| 		if (rootEl) scrollObserver?.observe(rootEl); |  | ||||||
| 	}); |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| watch([$$(backed), $$(contentEl)], () => { | /** | ||||||
| 	if (!backed) { |  * weakBackedがtrue→falseになったらexecuteQueue | ||||||
| 		if (!contentEl) return; |  */ | ||||||
|  | watch($$(weakBacked), () => { | ||||||
| 		scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE); | 	if (timelineBackTopBehavior.value === 'next' && !weakBacked) { | ||||||
| 	} else { | 		executeQueue(); | ||||||
| 		if (scrollRemove) scrollRemove(); |  | ||||||
| 		scrollRemove = null; |  | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
| 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); |  * Safariでは使わない方がいいかも? | ||||||
| }, { deep: true }); |  * @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> { | async function init(): Promise<void> { | ||||||
| 	items.value = new Map(); | 	items.value = new Map(); | ||||||
| 	queue.value = new Map(); | 	queue.value = new Map(); | ||||||
| @@ -210,7 +309,7 @@ async function init(): Promise<void> { | |||||||
| 			concatItems(res); | 			concatItems(res); | ||||||
| 			more.value = false; | 			more.value = false; | ||||||
| 		} else { | 		} else { | ||||||
| 			if (props.pagination.reversed) moreFetching.value = true; | 			moreFetching.value = true; | ||||||
| 			concatItems(res); | 			concatItems(res); | ||||||
| 			more.value = true; | 			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> => { | const fetchMore = async (): Promise<void> => { | ||||||
| 	if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; | 	if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; | ||||||
| 	moreFetching.value = true; | 	moreFetching.value = true; | ||||||
| @@ -246,29 +385,13 @@ const fetchMore = async (): Promise<void> => { | |||||||
| 			if (i === 10) item._shouldInsertAd_ = true; | 			if (i === 10) item._shouldInsertAd_ = true; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		const reverseConcat = _res => { | 		const reverseConcat = (_res) => adjustScroll(() => concatMapWithArray(items.value, _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(); |  | ||||||
| 			}); |  | ||||||
| 		}; |  | ||||||
|  |  | ||||||
| 		if (res.length === 0) { | 		if (res.length === 0) { | ||||||
| 			if (props.pagination.reversed) { | 			if (props.pagination.reversed) { | ||||||
| 				reverseConcat(res).then(() => { | 				reverseConcat(res); | ||||||
| 					more.value = false; | 				more.value = false; | ||||||
| 					moreFetching.value = false; | 				moreFetching.value = false; | ||||||
| 				}); |  | ||||||
| 			} else { | 			} else { | ||||||
| 				items.value = concatMapWithArray(items.value, res); | 				items.value = concatMapWithArray(items.value, res); | ||||||
| 				more.value = false; | 				more.value = false; | ||||||
| @@ -276,10 +399,9 @@ const fetchMore = async (): Promise<void> => { | |||||||
| 			} | 			} | ||||||
| 		} else { | 		} else { | ||||||
| 			if (props.pagination.reversed) { | 			if (props.pagination.reversed) { | ||||||
| 				reverseConcat(res).then(() => { | 				reverseConcat(res); | ||||||
| 					more.value = true; | 				more.value = true; | ||||||
| 					moreFetching.value = false; | 				moreFetching.value = false; | ||||||
| 				}); |  | ||||||
| 			} else { | 			} else { | ||||||
| 				items.value = concatMapWithArray(items.value, res); | 				items.value = concatMapWithArray(items.value, res); | ||||||
| 				more.value = true; | 				more.value = true; | ||||||
| @@ -344,25 +466,19 @@ const appearFetchMoreAhead = async (): Promise<void> => { | |||||||
| 	fetchMoreAppearTimeout(); | 	fetchMoreAppearTimeout(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE); | onActivated(() => { | ||||||
|  | 	nextTick(() => { | ||||||
|  | 		active.value = true; | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
|  |  | ||||||
| watch(visibility, () => { | onDeactivated(() => { | ||||||
| 	if (visibility.value === 'hidden') { | 	active.value = false; | ||||||
| 		timerForSetPause = window.setTimeout(() => { | }); | ||||||
| 			isPausingUpdate = true; |  | ||||||
| 			timerForSetPause = null; | watch([active, visibility], () => { | ||||||
| 		}, | 	if (!backed && active.value && visibility.value === 'visible') { | ||||||
| 		BACKGROUND_PAUSE_WAIT_SEC * 1000); | 		executeQueue(); | ||||||
| 	} else { // 'visible' |  | ||||||
| 		if (timerForSetPause) { |  | ||||||
| 			clearTimeout(timerForSetPause); |  | ||||||
| 			timerForSetPause = null; |  | ||||||
| 		} else { |  | ||||||
| 			isPausingUpdate = false; |  | ||||||
| 			if (isTop()) { |  | ||||||
| 				executeQueue(); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -378,19 +494,39 @@ const prepend = (item: MisskeyEntity): void => { | |||||||
| 		return; | 		return; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if (isTop() && !isPausingUpdate) unshiftItems([item]); | 	if ( | ||||||
| 	else prependQueue(item); | 		!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 newItems 新しいアイテムの配列 | ||||||
|  |  * @param limit デフォルトはdisplayLimit | ||||||
|  */ |  */ | ||||||
| function unshiftItems(newItems: MisskeyEntity[]) { | function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) { | ||||||
| 	const length = newItems.length + items.value.size; | 	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[]) { | function concatItems(oldItems: MisskeyEntity[]) { | ||||||
| 	const length = oldItems.length + items.value.size; | 	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() { | async function executeQueue() { | ||||||
| 	unshiftItems(Array.from(queue.value.values())); | 	// キューが空の場合でもタイムライン表示数を制限する役割がある | ||||||
| 	queue.value = new Map(); | 	// ため続行する! | ||||||
|  | 	// 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) { | 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(); | 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(() => { | onMounted(() => { | ||||||
| 	inited.then(() => { | 	active.value = true; | ||||||
| 		if (props.pagination.reversed) { | 	inited.then(scrollAfterInit); | ||||||
| 			nextTick(() => { |  | ||||||
| 				setTimeout(toBottom, 800); |  | ||||||
|  |  | ||||||
| 				// scrollToBottomでmoreFetchingボタンが画面外まで出るまで |  | ||||||
| 				// more = trueを遅らせる |  | ||||||
| 				setTimeout(() => { |  | ||||||
| 					moreFetching.value = false; |  | ||||||
| 				}, 2000); |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	}); |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| onBeforeUnmount(() => { | onBeforeUnmount(() => { | ||||||
| 	if (timerForSetPause) { |  | ||||||
| 		clearTimeout(timerForSetPause); |  | ||||||
| 		timerForSetPause = null; |  | ||||||
| 	} |  | ||||||
| 	if (preventAppearFetchMoreTimer.value) { | 	if (preventAppearFetchMoreTimer.value) { | ||||||
| 		clearTimeout(preventAppearFetchMoreTimer.value); | 		clearTimeout(preventAppearFetchMoreTimer.value); | ||||||
| 		preventAppearFetchMoreTimer.value = null; | 		preventAppearFetchMoreTimer.value = null; | ||||||
| 	} | 	} | ||||||
| 	scrollObserver?.disconnect(); | 	scrollObserver?.disconnect(); | ||||||
|  | 	if (scrollRemove) scrollRemove(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| defineExpose({ | defineExpose({ | ||||||
| 	items, | 	items, | ||||||
| 	queue, | 	queue, | ||||||
| 	backed, |  | ||||||
| 	more, | 	more, | ||||||
| 	inited, | 	inited, | ||||||
|  | 	queueSize, | ||||||
|  | 	backed: $$(backed), | ||||||
| 	reload, | 	reload, | ||||||
| 	prepend, | 	prepend, | ||||||
| 	append: appendItem, | 	append: appendItem, | ||||||
|   | |||||||
| @@ -4,7 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| --> | --> | ||||||
|  |  | ||||||
| <template> | <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> | </template> | ||||||
|  |  | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| @@ -14,6 +24,8 @@ import { useStream } from '@/stream'; | |||||||
| import * as sound from '@/scripts/sound'; | import * as sound from '@/scripts/sound'; | ||||||
| import { $i } from '@/account'; | import { $i } from '@/account'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
|  | import { instance } from '@/instance'; | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
| 	src: string; | 	src: string; | ||||||
| @@ -26,15 +38,22 @@ const props = defineProps<{ | |||||||
|  |  | ||||||
| const emit = defineEmits<{ | const emit = defineEmits<{ | ||||||
| 	(ev: 'note'): void; | 	(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')); | 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 => { | const prepend = note => { | ||||||
| 	tlComponent.pagingComponent?.prepend(note); | 	tlComponent?.pagingComponent?.prepend(note); | ||||||
|  |  | ||||||
| 	emit('note'); | 	emit('note'); | ||||||
|  |  | ||||||
| @@ -159,4 +178,48 @@ const timetravel = (date?: Date) => { | |||||||
| 	this.$refs.tl.reload(); | 	this.$refs.tl.reload(); | ||||||
| }; | }; | ||||||
| */ | */ | ||||||
|  |  | ||||||
|  | const reload = () => { | ||||||
|  | 	tlComponent?.pagingComponent?.reload(); | ||||||
|  | 	emit('reload'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ | ||||||
|  | 	reload, | ||||||
|  | 	queueSize, | ||||||
|  | }); | ||||||
| </script> | </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> | ||||||
| 	<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> | 	<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> | ||||||
| 		<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> | 		<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> | ||||||
| 		<div :class="$style.tlBody"> | 		<MkTimeline src="local" :class="$style.tlBody"/> | ||||||
| 			<MkTimeline src="local"/> |  | ||||||
| 		</div> |  | ||||||
| 	</div> | 	</div> | ||||||
| 	<div :class="$style.panel"> | 	<div :class="$style.panel"> | ||||||
| 		<XActiveUsersChart/> | 		<XActiveUsersChart/> | ||||||
|   | |||||||
| @@ -16,12 +16,18 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { inject, onBeforeUnmount, provide } from 'vue'; | import { computed, inject, onBeforeUnmount, provide, nextTick } from 'vue'; | ||||||
| import { Resolved, Router } from '@/nirax'; | import { NiraxChangeEvent, Resolved, Router } from '@/nirax'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
|  | import { getScrollContainer } from '@/scripts/scroll'; | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
| 	router?: Router; | 	router?: Router; | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Set any element if scroll position management needed | ||||||
|  | 	 */ | ||||||
|  | 	scrollContainer?: HTMLElement | null; | ||||||
| }>(); | }>(); | ||||||
|  |  | ||||||
| const router = props.router ?? inject('router'); | const router = props.router ?? inject('router'); | ||||||
| @@ -50,17 +56,49 @@ let currentPageComponent = $shallowRef(current.route.component); | |||||||
| let currentPageProps = $ref(current.props); | let currentPageProps = $ref(current.props); | ||||||
| let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); | let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); | ||||||
|  |  | ||||||
| function onChange({ resolved, key: newKey }) { | const scrollContainer = computed(() => props.scrollContainer ? (getScrollContainer(props.scrollContainer) ?? document.getElementsByTagName('html')[0]) : undefined); | ||||||
| 	const current = resolveNested(resolved); |  | ||||||
|  | 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; | 	if (current == null) return; | ||||||
| 	currentPageComponent = current.route.component; | 	currentPageComponent = current.route.component; | ||||||
| 	currentPageProps = current.props; | 	currentPageProps = current.props; | ||||||
| 	key = current.route.path + JSON.stringify(Object.fromEntries(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); | router.addListener('change', onChange); | ||||||
|  |  | ||||||
|  | function onSame() { | ||||||
|  | 	if (!scrollContainer.value) return; | ||||||
|  | 	scrollContainer.value.scroll({ top: 0, behavior: 'smooth' }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | router.addListener('same', onSame); | ||||||
|  |  | ||||||
| onBeforeUnmount(() => { | onBeforeUnmount(() => { | ||||||
| 	router.removeListener('change', onChange); | 	router.removeListener('change', onChange); | ||||||
|  | 	router.removeListener('same', onSame); | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -54,24 +54,30 @@ function parsePath(path: string): ParsedPath { | |||||||
| 	return res; | 	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<{ | export class Router extends EventEmitter<{ | ||||||
| 	change: (ctx: { | 	change: (ctx: NiraxChangeEvent) => void; | ||||||
| 		beforePath: string; | 	replace: (ctx: NiraxExportEvent) => void; | ||||||
| 		path: string; | 	push: (ctx: NiraxExportEvent) => void; | ||||||
| 		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; |  | ||||||
| 	same: () => void; | 	same: () => void; | ||||||
| }> { | }> { | ||||||
| 	private routes: RouteDef[]; | 	private routes: RouteDef[]; | ||||||
| @@ -276,29 +282,3 @@ export class Router extends EventEmitter<{ | |||||||
| 		this.navigate(path, key); | 		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> | 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||||
| 	<MkSpacer :contentMax="800"> | 	<MkSpacer :contentMax="800"> | ||||||
| 		<div ref="rootEl" v-hotkey.global="keymap"> | 		<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> | 			<MkTimeline | ||||||
| 			<div :class="$style.tl"> | 				ref="tlEl" :key="antennaId" | ||||||
| 				<MkTimeline | 				src="antenna" | ||||||
| 					ref="tlEl" :key="antennaId" | 				:antenna="antennaId" | ||||||
| 					src="antenna" | 				:sound="true" | ||||||
| 					:antenna="antennaId" | 				:class="$style.tl" | ||||||
| 					:sound="true" | 			/> | ||||||
| 					@queue="queueUpdated" |  | ||||||
| 				/> |  | ||||||
| 			</div> |  | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkSpacer> | 	</MkSpacer> | ||||||
| </MkStickyContainer> | </MkStickyContainer> | ||||||
| @@ -26,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { computed, watch } from 'vue'; | import { computed, watch } from 'vue'; | ||||||
| import MkTimeline from '@/components/MkTimeline.vue'; | import MkTimeline from '@/components/MkTimeline.vue'; | ||||||
| import { scroll } from '@/scripts/scroll'; |  | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { useRouter } from '@/router'; | import { useRouter } from '@/router'; | ||||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
| @@ -39,19 +35,14 @@ const props = defineProps<{ | |||||||
| }>(); | }>(); | ||||||
|  |  | ||||||
| let antenna = $ref(null); | let antenna = $ref(null); | ||||||
| let queue = $ref(0); |  | ||||||
| let rootEl = $shallowRef<HTMLElement>(); | let rootEl = $shallowRef<HTMLElement>(); | ||||||
| let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); | let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||||
| const keymap = $computed(() => ({ | const keymap = $computed(() => ({ | ||||||
| 	't': focus, | 	't': focus, | ||||||
| })); | })); | ||||||
|  |  | ||||||
| function queueUpdated(q) { |  | ||||||
| 	queue = q; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function top() { | function top() { | ||||||
| 	scroll(rootEl, { top: 0 }); | 	tlEl?.reload(); | ||||||
| } | } | ||||||
|  |  | ||||||
| async function timetravel() { | async function timetravel() { | ||||||
| @@ -96,25 +87,6 @@ definePageMetadata(computed(() => antenna ? { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style lang="scss" module> | <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 { | .tl { | ||||||
| 	background: var(--bg); | 	background: var(--bg); | ||||||
| 	border-radius: var(--radius); | 	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'"/> | 			<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> | ||||||
| 		<div v-else-if="tab === 'featured'"> | 		<div v-else-if="tab === 'featured'"> | ||||||
| 			<MkNotes :pagination="featuredPagination"/> | 			<MkNotes :pagination="featuredPagination"/> | ||||||
|   | |||||||
| @@ -26,10 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 	</MkRadios> | 	</MkRadios> | ||||||
|  |  | ||||||
| 	<FormSection> | 	<FormSection> | ||||||
| 		<div class="_gaps_s"> | 		<div class="_gaps_m"> | ||||||
| 			<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> | 			<div class="_gaps_s"> | ||||||
| 			<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> | 				<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> | ||||||
| 			<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></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> | 		</div> | ||||||
| 	</FormSection> | 	</FormSection> | ||||||
|  |  | ||||||
| @@ -193,6 +201,8 @@ import { unisonReload } from '@/scripts/unison-reload'; | |||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
| import { miLocalStorage } from '@/local-storage'; | import { miLocalStorage } from '@/local-storage'; | ||||||
|  | import { isWebKit } from '@/scripts/useragent'; | ||||||
|  | import { testNotification } from '@/scripts/test-notification'; | ||||||
| import { globalEvents } from '@/events'; | import { globalEvents } from '@/events'; | ||||||
| import { claimAchievement } from '@/scripts/achievements'; | import { claimAchievement } from '@/scripts/achievements'; | ||||||
|  |  | ||||||
| @@ -241,6 +251,7 @@ const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter(' | |||||||
| const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); | const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); | ||||||
| const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); | const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); | ||||||
| const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies')); | const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies')); | ||||||
|  | const timelineBackTopBehavior = computed(defaultStore.makeGetterSetter('timelineBackTopBehavior')); | ||||||
|  |  | ||||||
| watch(lang, () => { | watch(lang, () => { | ||||||
| 	miLocalStorage.setItem('lang', lang.value as string); | 	miLocalStorage.setItem('lang', lang.value as string); | ||||||
|   | |||||||
| @@ -92,6 +92,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ | |||||||
| 	'numberOfPageCache', | 	'numberOfPageCache', | ||||||
| 	'aiChanMode', | 	'aiChanMode', | ||||||
| 	'mediaListWithOneImageAppearance', | 	'mediaListWithOneImageAppearance', | ||||||
|  | 	'timelineBackTopBehavior', | ||||||
| ]; | ]; | ||||||
| const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ | const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ | ||||||
| 	'lightTheme', | 	'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);"/> | 			<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);"/> | 			<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> | 			<MkTimeline | ||||||
| 			<div :class="$style.tl"> | 				ref="tlComponent" | ||||||
| 				<MkTimeline | 				:key="src" | ||||||
| 					ref="tlComponent" | 				:src="src" | ||||||
| 					:key="src" | 				:sound="true" | ||||||
| 					:src="src" | 				:class="$style.tl" | ||||||
| 					:sound="true" | 			/> | ||||||
| 					@queue="queueUpdated" |  | ||||||
| 				/> |  | ||||||
| 			</div> |  | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkSpacer> | 	</MkSpacer> | ||||||
| </MkStickyContainer> | </MkStickyContainer> | ||||||
| @@ -31,7 +28,6 @@ import { defineAsyncComponent, computed, watch, provide } from 'vue'; | |||||||
| import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; | import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; | ||||||
| import MkTimeline from '@/components/MkTimeline.vue'; | import MkTimeline from '@/components/MkTimeline.vue'; | ||||||
| import MkPostForm from '@/components/MkPostForm.vue'; | import MkPostForm from '@/components/MkPostForm.vue'; | ||||||
| import { scroll } from '@/scripts/scroll'; |  | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| @@ -54,18 +50,11 @@ const keymap = { | |||||||
| const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>(); | const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||||
| const rootEl = $shallowRef<HTMLElement>(); | const rootEl = $shallowRef<HTMLElement>(); | ||||||
|  |  | ||||||
| let queue = $ref(0); |  | ||||||
| let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); | let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); | ||||||
| const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); | 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 { | function top(): void { | ||||||
| 	if (rootEl) scroll(rootEl, { top: 0 }); | 	tlComponent?.reload(); | ||||||
| } | } | ||||||
|  |  | ||||||
| async function chooseList(ev: MouseEvent): Promise<void> { | async function chooseList(ev: MouseEvent): Promise<void> { | ||||||
| @@ -184,25 +173,6 @@ definePageMetadata(computed(() => ({ | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style lang="scss" module> | <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 { | .postForm { | ||||||
| 	border-radius: var(--radius); | 	border-radius: var(--radius); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,16 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||||
| 	<MkSpacer :contentMax="800"> | 	<MkSpacer :contentMax="800"> | ||||||
| 		<div ref="rootEl"> | 		<div ref="rootEl"> | ||||||
| 			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> | 			<MkTimeline | ||||||
| 			<div :class="$style.tl"> | 				ref="tlEl" :key="listId" | ||||||
| 				<MkTimeline | 				src="list" | ||||||
| 					ref="tlEl" :key="listId" | 				:list="listId" | ||||||
| 					src="list" | 				:sound="true" | ||||||
| 					:list="listId" | 				:class="$style.tl" | ||||||
| 					:sound="true" | 			/> | ||||||
| 					@queue="queueUpdated" |  | ||||||
| 				/> |  | ||||||
| 			</div> |  | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkSpacer> | 	</MkSpacer> | ||||||
| </MkStickyContainer> | </MkStickyContainer> | ||||||
| @@ -26,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { computed, watch } from 'vue'; | import { computed, watch } from 'vue'; | ||||||
| import MkTimeline from '@/components/MkTimeline.vue'; | import MkTimeline from '@/components/MkTimeline.vue'; | ||||||
| import { scroll } from '@/scripts/scroll'; |  | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { useRouter } from '@/router'; | import { useRouter } from '@/router'; | ||||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
| @@ -39,7 +35,6 @@ const props = defineProps<{ | |||||||
| }>(); | }>(); | ||||||
|  |  | ||||||
| let list = $ref(null); | let list = $ref(null); | ||||||
| let queue = $ref(0); |  | ||||||
| let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); | let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||||
| let rootEl = $shallowRef<HTMLElement>(); | let rootEl = $shallowRef<HTMLElement>(); | ||||||
|  |  | ||||||
| @@ -49,12 +44,8 @@ watch(() => props.listId, async () => { | |||||||
| 	}); | 	}); | ||||||
| }, { immediate: true }); | }, { immediate: true }); | ||||||
|  |  | ||||||
| function queueUpdated(q) { |  | ||||||
| 	queue = q; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function top() { | function top() { | ||||||
| 	scroll(rootEl, { top: 0 }); | 	tlEl?.reload(); | ||||||
| } | } | ||||||
|  |  | ||||||
| function settings() { | function settings() { | ||||||
| @@ -89,24 +80,6 @@ definePageMetadata(computed(() => list ? { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style lang="scss" module> | <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 { | .tl { | ||||||
| 	background: var(--bg); | 	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) { | export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) { | ||||||
| 	// とりあえず評価してみる | 	// とりあえず評価してみる | ||||||
| 	if (el.isConnected && isTopVisible(el)) { | 	if (el.isConnected && isTopVisible(el, tolerance)) { | ||||||
| 		cb(); | 		cb(); | ||||||
| 		if (once) return null; | 		if (once) return null; | ||||||
| 	} | 	} | ||||||
| @@ -75,12 +75,29 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 | |||||||
| 	return removeListener; | 	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); | 		window.scroll(options); | ||||||
| 	} else { | 	} 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 el Scroll container element | ||||||
|  * @param options Scroll options |  * @param options Scroll options | ||||||
|  */ |  */ | ||||||
| export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) { | export function scrollToTop(el: HTMLElement | null, options: { behavior?: ScrollBehavior; } = {}) { | ||||||
| 	scroll(el, { top: 0, ...options }); | 	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 { markRaw, ref } from 'vue'; | ||||||
| import misskey from 'misskey-js'; | import misskey from 'misskey-js'; | ||||||
| import { Storage } from './pizzax'; | import { Storage } from './pizzax'; | ||||||
|  | import { isWebKit } from './scripts/useragent'; | ||||||
|  |  | ||||||
| interface PostFormAction { | interface PostFormAction { | ||||||
| 	title: string, | 	title: string, | ||||||
| @@ -352,6 +353,10 @@ export const defaultStore = markRaw(new Storage('base', { | |||||||
| 		where: 'device', | 		where: 'device', | ||||||
| 		default: {} as Record<string, Record<string, string[]>>, | 		default: {} as Record<string, Record<string, string[]>>, | ||||||
| 	}, | 	}, | ||||||
|  | 	timelineBackTopBehavior: { | ||||||
|  | 		where: 'device', | ||||||
|  | 		default: (isWebKit() ? 'newest' : 'next') as 'newest' | 'next', | ||||||
|  | 	}, | ||||||
| })); | })); | ||||||
|  |  | ||||||
| // TODO: 他のタブと永続化されたstateを同期 | // TODO: 他のタブと永続化されたstateを同期 | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
| 	<div ref="contents"> | 	<div ref="contents"> | ||||||
| 		<RouterView @contextmenu.stop="onContextmenu"/> | 		<RouterView :scrollContainer="contents" @contextmenu.stop="onContextmenu"/> | ||||||
| 	</div> | 	</div> | ||||||
| </XColumn> | </XColumn> | ||||||
| </template> | </template> | ||||||
| @@ -26,8 +26,6 @@ import * as os from '@/os'; | |||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { mainRouter } from '@/router'; | import { mainRouter } from '@/router'; | ||||||
| import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | ||||||
| import { useScrollPositionManager } from '@/nirax'; |  | ||||||
| import { getScrollContainer } from '@/scripts/scroll'; |  | ||||||
|  |  | ||||||
| defineProps<{ | defineProps<{ | ||||||
| 	column: Column; | 	column: Column; | ||||||
| @@ -71,6 +69,4 @@ function onContextmenu(ev: MouseEvent) { | |||||||
| 		}, | 		}, | ||||||
| 	}], ev); | 	}], ev); | ||||||
| } | } | ||||||
|  |  | ||||||
| useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter); |  | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -13,14 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 		<span style="margin-left: 8px;">{{ column.name }}</span> | 		<span style="margin-left: 8px;">{{ column.name }}</span> | ||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
| 	<div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> | 	<MkTimeline v-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/> | ||||||
| 		<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"/> |  | ||||||
| </XColumn> | </XColumn> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| @@ -30,27 +23,16 @@ import XColumn from './column.vue'; | |||||||
| import { removeColumn, updateColumn, Column } from './deck-store'; | import { removeColumn, updateColumn, Column } from './deck-store'; | ||||||
| import MkTimeline from '@/components/MkTimeline.vue'; | import MkTimeline from '@/components/MkTimeline.vue'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { $i } from '@/account'; |  | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { instance } from '@/instance'; |  | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
| 	column: Column; | 	column: Column; | ||||||
| 	isStacked: boolean; | 	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(() => { | onMounted(() => { | ||||||
| 	if (props.column.tl == null) { | 	if (props.column.tl == null) { | ||||||
| 		setType(); | 		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, | 	action: setType, | ||||||
| }]; | }]; | ||||||
| </script> | </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"/> | 				<XStatusBars :class="$style.statusbars"/> | ||||||
| 			</div> | 			</div> | ||||||
| 		</template> | 		</template> | ||||||
| 		<RouterView/> | 		<RouterView :scrollContainer="contents?.rootEl"/> | ||||||
| 		<div :class="$style.spacer"></div> | 		<div :class="$style.spacer"></div> | ||||||
| 	</MkStickyContainer> | 	</MkStickyContainer> | ||||||
|  |  | ||||||
| @@ -105,7 +105,6 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; | |||||||
| import { deviceKind } from '@/scripts/device-kind'; | import { deviceKind } from '@/scripts/device-kind'; | ||||||
| import { miLocalStorage } from '@/local-storage'; | import { miLocalStorage } from '@/local-storage'; | ||||||
| import { CURRENT_STICKY_BOTTOM } from '@/const'; | import { CURRENT_STICKY_BOTTOM } from '@/const'; | ||||||
| import { useScrollPositionManager } from '@/nirax'; |  | ||||||
|  |  | ||||||
| const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); | const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); | ||||||
| const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); | const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); | ||||||
| @@ -227,8 +226,6 @@ watch($$(navFooter), () => { | |||||||
| }, { | }, { | ||||||
| 	immediate: true, | 	immediate: true, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| useScrollPositionManager(() => contents.value.rootEl, mainRouter); |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style> | <style> | ||||||
|   | |||||||
| @@ -20,33 +20,20 @@ SPDX-License-Identifier: AGPL-3.0-only | |||||||
| 		</button> | 		</button> | ||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
| 	<div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled"> | 	<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"/> | ||||||
| 		<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> |  | ||||||
| </MkContainer> | </MkContainer> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { ref } from 'vue'; | 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 { GetFormResultType } from '@/scripts/form'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import MkContainer from '@/components/MkContainer.vue'; | import MkContainer from '@/components/MkContainer.vue'; | ||||||
| import MkTimeline from '@/components/MkTimeline.vue'; | import MkTimeline from '@/components/MkTimeline.vue'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { $i } from '@/account'; |  | ||||||
| import { instance } from '@/instance'; |  | ||||||
|  |  | ||||||
| const name = 'timeline'; | 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 = { | const widgetPropsDef = { | ||||||
| 	showHeader: { | 	showHeader: { | ||||||
| @@ -141,17 +128,3 @@ defineExpose<WidgetComponentExpose>({ | |||||||
| 	id: props.widget ? props.widget.id : null, | 	id: props.widget ? props.widget.id : null, | ||||||
| }); | }); | ||||||
| </script> | </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: |       typescript: | ||||||
|         specifier: 5.2.2 |         specifier: 5.2.2 | ||||||
|         version: 5.2.2 |         version: 5.2.2 | ||||||
|  |       ua-parser-js: | ||||||
|  |         specifier: 2.0.0-alpha.2 | ||||||
|  |         version: 2.0.0-alpha.2 | ||||||
|       uuid: |       uuid: | ||||||
|         specifier: 9.0.1 |         specifier: 9.0.1 | ||||||
|         version: 9.0.1 |         version: 9.0.1 | ||||||
| @@ -11846,6 +11849,7 @@ packages: | |||||||
|   /form-data@3.0.1: |   /form-data@3.0.1: | ||||||
|     resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} |     resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} | ||||||
|     engines: {node: '>= 6'} |     engines: {node: '>= 6'} | ||||||
|  |     requiresBuild: true | ||||||
|     dependencies: |     dependencies: | ||||||
|       asynckit: 0.4.0 |       asynckit: 0.4.0 | ||||||
|       combined-stream: 1.0.8 |       combined-stream: 1.0.8 | ||||||
| @@ -18818,6 +18822,10 @@ packages: | |||||||
|     engines: {node: '>=14.17'} |     engines: {node: '>=14.17'} | ||||||
|     hasBin: true |     hasBin: true | ||||||
|  |  | ||||||
|  |   /ua-parser-js@2.0.0-alpha.2: | ||||||
|  |     resolution: {integrity: sha512-Vz+BJN/EFC1OaUv0eu5kPyX7HEZIO7Dv29jIK7rMuKjUB1qqq+Is/XIpu5iV5XDvoNl62dM7ay8DtzYjBDI0WA==} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /ufo@1.1.2: |   /ufo@1.1.2: | ||||||
|     resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} |     resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} | ||||||
|     dev: true |     dev: true | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user