Compare commits
	
		
			39 commits
		
	
	
		
			develop
			...
			fix-msg-ro
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | af2603ef8a | ||
|  | 8913c751f0 | ||
|  | 7a4d617699 | ||
|  | b96e98b0f1 | ||
|  | ddbf8c7873 | ||
|  | 1056380733 | ||
|  | 37f78378d5 | ||
|  | a7dd3e9324 | ||
|  | a2f16c1364 | ||
|  | de90b25561 | ||
|  | 08ae09a871 | ||
|  | ba21a298ff | ||
|  | fa809bb50c | ||
|  | 90c6f15a72 | ||
|  | 99d8172ae5 | ||
|  | b88fc1fb4e | ||
|  | 9061d33405 | ||
|  | 8eaf1423c1 | ||
|  | edb36d73f8 | ||
|  | 927317b5bb | ||
|  | eaaccb52da | ||
|  | 9825d7ce87 | ||
|  | 6fc1043e1b | ||
|  | 1f4d211ff7 | ||
|  | f0bb08de15 | ||
|  | 7af5562b5a | ||
|  | 60ad28cbc7 | ||
|  | f660782a44 | ||
|  | 410939a524 | ||
|  | 4a7f968741 | ||
|  | 19af8e845f | ||
|  | 9923cfaf50 | ||
|  | dd0d86cbb6 | ||
|  | a8af328e5b | ||
|  | 364ac37c0a | ||
|  | 6f9ccf6b02 | ||
|  | c61d6bd89a | ||
|  | 8a7264835e | ||
|  | a1f346a549 | 
					 23 changed files with 977 additions and 849 deletions
				
			
		|  | @ -1,13 +1,14 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, h, PropType, TransitionGroup } from 'vue'; | import { defineComponent, getCurrentInstance, h, markRaw, onMounted, PropType, TransitionGroup } from 'vue'; | ||||||
| import MkAd from '@/components/global/ad.vue'; | import MkAd from '@/components/global/ad.vue'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
|  | import { MisskeyEntity } from '@/types/date-separated-list'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
| 	props: { | 	props: { | ||||||
| 		items: { | 		items: { | ||||||
| 			type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, | 			type: Array as PropType<MisskeyEntity[]>, | ||||||
| 			required: true, | 			required: true, | ||||||
| 		}, | 		}, | ||||||
| 		direction: { | 		direction: { | ||||||
|  | @ -30,9 +31,15 @@ export default defineComponent({ | ||||||
| 			required: false, | 			required: false, | ||||||
| 			default: false | 			default: false | ||||||
| 		}, | 		}, | ||||||
|  | 		itemsContainer: { | ||||||
|  | 			type: Object as PropType<HTMLElement | null>, | ||||||
|  | 			required: false, | ||||||
|  | 			nullable: true, | ||||||
|  | 			default: null, | ||||||
|  | 		}, | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	setup(props, { slots, expose }) { | 	setup(props, { slots, expose, emit }) { | ||||||
| 		function getDateText(time: string) { | 		function getDateText(time: string) { | ||||||
| 			const date = new Date(time).getDate(); | 			const date = new Date(time).getDate(); | ||||||
| 			const month = new Date(time).getMonth() + 1; | 			const month = new Date(time).getMonth() + 1; | ||||||
|  | @ -90,17 +97,36 @@ export default defineComponent({ | ||||||
| 			} | 			} | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
|  | 		onMounted(() => { | ||||||
|  | 			const el = getCurrentInstance()?.vnode.el; | ||||||
|  | 			emit('update:itemsContainer', el ? markRaw(el) : null); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		function onBeforeLeave(el: HTMLElement) { | ||||||
|  | 			el.style.top = `${el.offsetTop}px`; | ||||||
|  | 			el.style.left = `${el.offsetLeft}px`; | ||||||
|  | 		} | ||||||
|  | 		function onLeaveCanceled(el: HTMLElement) { | ||||||
|  | 			el.style.top = ''; | ||||||
|  | 			el.style.left = ''; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
| 		return () => h( | 		return () => h( | ||||||
| 			defaultStore.state.animation ? TransitionGroup : 'div', | 			defaultStore.state.animation ? TransitionGroup : 'div', | ||||||
| 			defaultStore.state.animation ? { | 			{ | ||||||
| 					class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), | 					class: { | ||||||
| 					name: 'list', | 						'sqadhkmv': true, | ||||||
| 					tag: 'div', | 						'noGap': props.noGap | ||||||
|  | 					}, | ||||||
| 					'data-direction': props.direction, | 					'data-direction': props.direction, | ||||||
| 					'data-reversed': props.reversed ? 'true' : 'false', | 					'data-reversed': props.reversed ? 'true' : 'false', | ||||||
| 				} : { | 					...(defaultStore.state.animation ? { | ||||||
| 					class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), | 						name: 'list', | ||||||
| 				}, | 						tag: 'div', | ||||||
|  | 						onBeforeLeave, | ||||||
|  | 						onLeaveCanceled, | ||||||
|  | 					} : {}), | ||||||
|  | 			}, | ||||||
| 			{ default: renderChildren }); | 			{ default: renderChildren }); | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  | @ -108,6 +134,8 @@ export default defineComponent({ | ||||||
| 
 | 
 | ||||||
| <style lang="scss"> | <style lang="scss"> | ||||||
| .sqadhkmv { | .sqadhkmv { | ||||||
|  | 	display: flex; | ||||||
|  | 
 | ||||||
| 	> *:empty { | 	> *:empty { | ||||||
| 		display: none; | 		display: none; | ||||||
| 	} | 	} | ||||||
|  | @ -120,24 +148,46 @@ export default defineComponent({ | ||||||
| 		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 { | ||||||
|  | 		transition: none !important; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	> .list-leave-active, | ||||||
| 	> .list-enter-active { | 	> .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); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	> .list-leave-from, | ||||||
|  | 	> .list-leave-to, | ||||||
|  | 	> .list-leave-active { | ||||||
|  | 		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); | ||||||
|  | 		position: absolute !important; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	&[data-direction="up"] { | 	&[data-direction="up"] { | ||||||
| 		> .list-enter-from { | 		> .list-enter-from, | ||||||
|  | 		> .list-leave-to { | ||||||
| 			opacity: 0; | 			opacity: 0; | ||||||
| 			transform: translateY(64px); | 			transform: translateY(64px); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	&[data-direction="down"] { | 	&[data-direction="down"] { | ||||||
| 		> .list-enter-from { | 		> .list-enter-from, | ||||||
|  | 		> .list-leave-to { | ||||||
| 			opacity: 0; | 			opacity: 0; | ||||||
| 			transform: translateY(-64px); | 			transform: translateY(-64px); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	&[data-reversed="true"] { | ||||||
|  | 		flex-direction: column-reverse; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	&[data-reversed="false"] { | ||||||
|  | 		flex-direction: column; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	> .separator { | 	> .separator { | ||||||
| 		text-align: center; | 		text-align: center; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,71 +1,63 @@ | ||||||
| <template> | <template> | ||||||
| <div ref="rootEl"> | <div ref="rootEl"> | ||||||
| 	<slot name="header"></slot> | 	<slot name="header"></slot> | ||||||
| 	<div ref="bodyEl"> | 	<div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> | ||||||
| 		<slot></slot> | 		<slot></slot> | ||||||
| 	</div> | 	</div> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; | import { onMounted, onUnmounted } from 'vue'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | const props = withDefaults(defineProps<{ | ||||||
| 	props: { | 	autoSticky?: boolean; | ||||||
| 		autoSticky: { | }>(), { | ||||||
| 			type: Boolean, | 	autoSticky: false, | ||||||
| 			required: false, | }) | ||||||
| 			default: false, |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| 
 | 
 | ||||||
| 	setup(props, context) { | const rootEl = $ref<HTMLElement>(); | ||||||
| 		const rootEl = ref<HTMLElement>(null); | const bodyEl = $ref<HTMLElement>(); | ||||||
| 		const bodyEl = ref<HTMLElement>(null); |  | ||||||
| 
 | 
 | ||||||
| 		const calc = () => { | let headerHeight: string | undefined = $ref(); | ||||||
| 			const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; |  | ||||||
| 
 | 
 | ||||||
| 			const header = rootEl.value.children[0]; | const calc = () => { | ||||||
| 			if (header === bodyEl.value) { | 	const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px'; | ||||||
| 				bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); |  | ||||||
| 			} else { |  | ||||||
| 				bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); |  | ||||||
| 
 | 
 | ||||||
| 				if (props.autoSticky) { | 	const header = rootEl.children[0] as HTMLElement; | ||||||
| 					header.style.setProperty('--stickyTop', currentStickyTop); | 	if (header === bodyEl) { | ||||||
| 					header.style.position = 'sticky'; | 		bodyEl.style.setProperty('--stickyTop', currentStickyTop); | ||||||
| 					header.style.top = 'var(--stickyTop)'; | 	} else { | ||||||
| 					header.style.zIndex = '1'; | 		bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); | ||||||
| 				} | 		headerHeight = header.offsetHeight.toString(); | ||||||
| 			} |  | ||||||
| 		}; |  | ||||||
| 
 | 
 | ||||||
| 		onMounted(() => { | 		if (props.autoSticky) { | ||||||
| 			calc(); | 			header.style.setProperty('--stickyTop', currentStickyTop); | ||||||
|  | 			header.style.position = 'sticky'; | ||||||
|  | 			header.style.top = 'var(--stickyTop)'; | ||||||
|  | 			header.style.zIndex = '1'; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| 			const observer = new MutationObserver(() => { | const observer = new MutationObserver(() => { | ||||||
| 				window.setTimeout(() => { | 	window.setTimeout(() => { | ||||||
| 					calc(); | 		calc(); | ||||||
| 				}, 100); | 	}, 100); | ||||||
| 			}); | }); | ||||||
| 
 | 
 | ||||||
| 			observer.observe(rootEl.value, { | onMounted(() => { | ||||||
| 				attributes: false, | 	calc(); | ||||||
| 				childList: true, |  | ||||||
| 				subtree: false, |  | ||||||
| 			}); |  | ||||||
| 
 | 
 | ||||||
| 			onUnmounted(() => { | 	observer.observe(rootEl, { | ||||||
| 				observer.disconnect(); | 		attributes: false, | ||||||
| 			}); | 		childList: true, | ||||||
| 		}); | 		subtree: false, | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| 		return { | onUnmounted(() => { | ||||||
| 			rootEl, | 	observer.disconnect(); | ||||||
| 			bodyEl, |  | ||||||
| 		}; |  | ||||||
| 	}, |  | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,9 +7,19 @@ | ||||||
| 		</div> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
| 
 | 
 | ||||||
| 	<template #default="{ items: notes }"> | 	<template #default="{ items: notes, itemsContainerWrapped }"> | ||||||
| 		<div class="giivymft" :class="{ noGap }"> | 		<div class="giivymft" :class="{ noGap }"> | ||||||
| 			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes"> | 			<XList | ||||||
|  | 				ref="notes" | ||||||
|  | 				v-slot="{ item: note }" | ||||||
|  | 				:items="notes" | ||||||
|  | 				:direction="pagination.reversed ? 'up' : 'down'" | ||||||
|  | 				:reversed="pagination.reversed" | ||||||
|  | 				:no-gap="noGap" | ||||||
|  | 				:ad="true" | ||||||
|  | 				v-model:itemsContainer="itemsContainerWrapped.v.value" | ||||||
|  | 				class="notes" | ||||||
|  | 			> | ||||||
| 				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/> | 				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/> | ||||||
| 			</XList> | 			</XList> | ||||||
| 		</div> | 		</div> | ||||||
|  |  | ||||||
|  | @ -7,8 +7,8 @@ | ||||||
| 		</div> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
| 
 | 
 | ||||||
| 	<template #default="{ items: notifications }"> | 	<template #default="{ items: notifications, itemsContainerWrapped }"> | ||||||
| 		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> | 		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true" v-model:itemsContainer="itemsContainerWrapped.v.value"> | ||||||
| 			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | 			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | ||||||
| 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> | 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> | ||||||
| 		</XList> | 		</XList> | ||||||
|  | @ -29,7 +29,7 @@ import { stream } from '@/stream'; | ||||||
| import { $i } from '@/account'; | import { $i } from '@/account'; | ||||||
| 
 | 
 | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
| 	includeTypes?: PropType<typeof notificationTypes[number][]>; | 	includeTypes?: typeof notificationTypes[number][]; | ||||||
| 	unreadOnly?: boolean; | 	unreadOnly?: boolean; | ||||||
| }>(); | }>(); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -14,9 +14,15 @@ | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
| 	<div v-else ref="rootEl"> | 	<div v-else ref="rootEl"> | ||||||
| 		<slot :items="items"></slot> | 		<div v-if="pagination.reversed" v-show="more" key="_more_" class="cxiknjgy _gap"> | ||||||
| 		<div v-show="more" key="_more_" class="cxiknjgy _gap"> | 			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> | ||||||
| 			<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> | 				{{ $ts.loadMore }} | ||||||
|  | 			</MkButton> | ||||||
|  | 			<MkLoading v-else class="loading"/> | ||||||
|  | 		</div> | ||||||
|  | 		<slot :items="items" :fetching="fetching || moreFetching" :itemsContainer="itemsContainer" :itemsContainerWrapped="itemsContainerWrapped"></slot> | ||||||
|  | 		<div v-if="!pagination.reversed" v-show="more" key="_more_" class="cxiknjgy _gap"> | ||||||
|  | 			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> | ||||||
| 				{{ $ts.loadMore }} | 				{{ $ts.loadMore }} | ||||||
| 			</MkButton> | 			</MkButton> | ||||||
| 			<MkLoading v-else class="loading"/> | 			<MkLoading v-else class="loading"/> | ||||||
|  | @ -25,14 +31,17 @@ | ||||||
| </transition> | </transition> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts" setup> | <script lang="ts"> | ||||||
| import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, 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, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; | import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll'; | ||||||
| import MkButton from '@/components/ui/button.vue'; | import MkButton from '@/components/ui/button.vue'; | ||||||
|  | import { defaultStore } from '@/store'; | ||||||
|  | import { MisskeyEntity } from '@/types/date-separated-list'; | ||||||
| 
 | 
 | ||||||
| const SECOND_FETCH_LIMIT = 30; | const SECOND_FETCH_LIMIT = 30; | ||||||
|  | const ASOBI = 16; | ||||||
| 
 | 
 | ||||||
| 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; | ||||||
|  | @ -51,8 +60,11 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> | ||||||
| 	reversed?: boolean; | 	reversed?: boolean; | ||||||
| 
 | 
 | ||||||
| 	offsetMode?: boolean; | 	offsetMode?: boolean; | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
|  | 	pageEl?: HTMLElement; | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | <script lang="ts" setup> | ||||||
| const props = withDefaults(defineProps<{ | const props = withDefaults(defineProps<{ | ||||||
| 	pagination: Paging; | 	pagination: Paging; | ||||||
| 	disableAutoLoad?: boolean; | 	disableAutoLoad?: boolean; | ||||||
|  | @ -65,21 +77,90 @@ const emit = defineEmits<{ | ||||||
| 	(e: 'queue', count: number): void; | 	(e: 'queue', count: number): void; | ||||||
| }>(); | }>(); | ||||||
| 
 | 
 | ||||||
| type Item = { id: string; [another: string]: unknown; }; | let rootEl = $ref<HTMLElement>(); | ||||||
| 
 | 
 | ||||||
| const rootEl = ref<HTMLElement>(); | /* | ||||||
| const items = ref<Item[]>([]); |  * itemsContainer: itemsの実体DOMsの親コンテナ(=v-forの直上)のHTMLElement | ||||||
| const queue = ref<Item[]>([]); |  * | ||||||
|  |  * IntersectionObserverを使用してスクロールのパフォーマンスを向上させるため必要 | ||||||
|  |  * この中の最初の要素を評価するので、順番を反転したり変えたりしてはいけない | ||||||
|  |  *  | ||||||
|  |  * これがundefinedのままの場合はrootElにフォールバックする | ||||||
|  |  * つまりrootElがitemsの実体DOMsの親であるとする | ||||||
|  |  *  | ||||||
|  |  * 自動ロードやストリーミングでの追加がなければあまり関係ない | ||||||
|  |  */ | ||||||
|  | let itemsContainer = $ref<HTMLElement | null>(); | ||||||
|  | /* | ||||||
|  |  * date-separated-listとやり取りするために入れ子にしたオブジェクトを用意する | ||||||
|  |  * slotの中身から変数を直接書き込むことができないため | ||||||
|  |  */ | ||||||
|  | const itemsContainerWrapped = { v: $$(itemsContainer) }; | ||||||
|  | 
 | ||||||
|  | /* | ||||||
|  |  * 遡り中かどうか | ||||||
|  |  * = (itemsContainer || rootEl).children.item(0) が画面内に入ったかどうか | ||||||
|  |  */ | ||||||
|  | let backed = $ref(false); | ||||||
|  | 
 | ||||||
|  | let scrollRemove: (() => void) | null = $ref(null); | ||||||
|  | 
 | ||||||
|  | const items = ref<MisskeyEntity[]>([]); | ||||||
|  | const queue = ref<MisskeyEntity[]>([]); | ||||||
| const offset = ref(0); | const offset = ref(0); | ||||||
| const fetching = ref(true); | const fetching = ref(true); | ||||||
| const moreFetching = ref(false); | const moreFetching = ref(false); | ||||||
| const more = ref(false); | const more = ref(false); | ||||||
| const backed = ref(false); // 遡り中か否か |  | ||||||
| const isBackTop = ref(false); | const isBackTop = ref(false); | ||||||
| const empty = computed(() => items.value.length === 0); | const empty = computed(() => items.value.length === 0); | ||||||
| const error = ref(false); | const error = ref(false); | ||||||
|  | const { | ||||||
|  | 	enableInfiniteScroll | ||||||
|  | } = defaultStore.reactiveState; | ||||||
| 
 | 
 | ||||||
| const init = async (): Promise<void> => { | const contentEl = $computed(() => props.pagination.pageEl || rootEl); | ||||||
|  | const scrollableElement = $computed(() => getScrollContainer(contentEl)); | ||||||
|  | 
 | ||||||
|  | const observer = new IntersectionObserver(entries => { | ||||||
|  | 	if (entries.some(entry => entry.isIntersecting)) { | ||||||
|  | 		backed = false; | ||||||
|  | 	} else { | ||||||
|  | 		backed = true; | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | watch([$$(itemsContainer), $$(rootEl)], observeLatestElement); | ||||||
|  | watch(items, observeLatestElement, { deep: true }); | ||||||
|  | 
 | ||||||
|  | function observeLatestElement() { | ||||||
|  | 	observer.disconnect(); | ||||||
|  | 	nextTick(() => { | ||||||
|  | 		const latestEl = (itemsContainer || rootEl)?.children.item(0); | ||||||
|  | 		if (latestEl) observer.observe(latestEl); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | watch([$$(backed), $$(contentEl)], () => { | ||||||
|  | 	if (!backed) { | ||||||
|  | 		if (!contentEl) return; | ||||||
|  | 
 | ||||||
|  | 		scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, ASOBI); | ||||||
|  | 	} else { | ||||||
|  | 		if (scrollRemove) scrollRemove(); | ||||||
|  | 		scrollRemove = null; | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | if (props.pagination.params && isRef(props.pagination.params)) { | ||||||
|  | 	watch(props.pagination.params, init, { deep: true }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | watch(queue, (a, b) => { | ||||||
|  | 	if (a.length === 0 && b.length === 0) return; | ||||||
|  | 	emit('queue', queue.value.length); | ||||||
|  | }, { deep: true }); | ||||||
|  | 
 | ||||||
|  | async function init(): Promise<void> { | ||||||
| 	queue.value = []; | 	queue.value = []; | ||||||
| 	fetching.value = true; | 	fetching.value = true; | ||||||
| 	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; | 	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; | ||||||
|  | @ -89,18 +170,15 @@ const init = async (): Promise<void> => { | ||||||
| 	}).then(res => { | 	}).then(res => { | ||||||
| 		for (let i = 0; i < res.length; i++) { | 		for (let i = 0; i < res.length; i++) { | ||||||
| 			const item = res[i]; | 			const item = res[i]; | ||||||
| 			if (props.pagination.reversed) { | 			if (i === 3) item._shouldInsertAd_ = true; | ||||||
| 				if (i === res.length - 2) item._shouldInsertAd_ = true; |  | ||||||
| 			} else { |  | ||||||
| 				if (i === 3) item._shouldInsertAd_ = true; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 		if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { | 		if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { | ||||||
| 			res.pop(); | 			res.pop(); | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse() : res; | 			if (props.pagination.reversed) moreFetching.value = true; | ||||||
|  | 			items.value = res; | ||||||
| 			more.value = true; | 			more.value = true; | ||||||
| 		} else { | 		} else { | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse() : res; | 			items.value = res; | ||||||
| 			more.value = false; | 			more.value = false; | ||||||
| 		} | 		} | ||||||
| 		offset.value = res.length; | 		offset.value = res.length; | ||||||
|  | @ -112,15 +190,14 @@ const init = async (): Promise<void> => { | ||||||
| 	}); | 	}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const reload = (): void => { | const reload = (): Promise<void> => { | ||||||
| 	items.value = []; | 	items.value = []; | ||||||
| 	init(); | 	return init(); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const fetchMore = async (): Promise<void> => { | const fetchMore = async (): Promise<void> => { | ||||||
| 	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; | 	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; | ||||||
| 	moreFetching.value = true; | 	moreFetching.value = true; | ||||||
| 	backed.value = true; |  | ||||||
| 	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; | 	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; | ||||||
| 	await os.api(props.pagination.endpoint, { | 	await os.api(props.pagination.endpoint, { | ||||||
| 		...params, | 		...params, | ||||||
|  | @ -128,27 +205,57 @@ const fetchMore = async (): Promise<void> => { | ||||||
| 		...(props.pagination.offsetMode ? { | 		...(props.pagination.offsetMode ? { | ||||||
| 			offset: offset.value, | 			offset: offset.value, | ||||||
| 		} : { | 		} : { | ||||||
| 			untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, | 			untilId: items.value[items.value.length - 1].id, | ||||||
| 		}), | 		}), | ||||||
| 	}).then(res => { | 	}).then(res => { | ||||||
| 		for (let i = 0; i < res.length; i++) { | 		for (let i = 0; i < res.length; i++) { | ||||||
| 			const item = res[i]; | 			const item = res[i]; | ||||||
| 			if (props.pagination.reversed) { | 			if (i === 10) item._shouldInsertAd_ = true; | ||||||
| 				if (i === res.length - 9) item._shouldInsertAd_ = true; |  | ||||||
| 			} else { |  | ||||||
| 				if (i === 10) item._shouldInsertAd_ = true; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		const reverseConcat = _res => { | ||||||
|  | 			const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); | ||||||
|  | 			const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; | ||||||
|  | 
 | ||||||
|  | 			items.value = items.value.concat(_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 > SECOND_FETCH_LIMIT) { | 		if (res.length > SECOND_FETCH_LIMIT) { | ||||||
| 			res.pop(); | 			res.pop(); | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | 
 | ||||||
| 			more.value = true; | 			if (props.pagination.reversed) { | ||||||
|  | 				reverseConcat(res).then(() => { | ||||||
|  | 					more.value = true; | ||||||
|  | 					moreFetching.value = false; | ||||||
|  | 				}); | ||||||
|  | 			} else { | ||||||
|  | 				items.value = items.value.concat(res); | ||||||
|  | 				more.value = true; | ||||||
|  | 				moreFetching.value = false; | ||||||
|  | 			} | ||||||
| 		} else { | 		} else { | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | 			if (props.pagination.reversed) { | ||||||
| 			more.value = false; | 				reverseConcat(res).then(() => { | ||||||
|  | 					more.value = false; | ||||||
|  | 					moreFetching.value = false; | ||||||
|  | 				}); | ||||||
|  | 			} else { | ||||||
|  | 				items.value = items.value.concat(res); | ||||||
|  | 				more.value = false; | ||||||
|  | 				moreFetching.value = false; | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 		offset.value += res.length; | 		offset.value += res.length; | ||||||
| 		moreFetching.value = false; |  | ||||||
| 	}, e => { | 	}, e => { | ||||||
| 		moreFetching.value = false; | 		moreFetching.value = false; | ||||||
| 	}); | 	}); | ||||||
|  | @ -164,15 +271,15 @@ const fetchMoreAhead = async (): Promise<void> => { | ||||||
| 		...(props.pagination.offsetMode ? { | 		...(props.pagination.offsetMode ? { | ||||||
| 			offset: offset.value, | 			offset: offset.value, | ||||||
| 		} : { | 		} : { | ||||||
| 			sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, | 			sinceId: items.value[items.value.length - 1].id, | ||||||
| 		}), | 		}), | ||||||
| 	}).then(res => { | 	}).then(res => { | ||||||
| 		if (res.length > SECOND_FETCH_LIMIT) { | 		if (res.length > SECOND_FETCH_LIMIT) { | ||||||
| 			res.pop(); | 			res.pop(); | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | 			items.value = items.value.concat(res); | ||||||
| 			more.value = true; | 			more.value = true; | ||||||
| 		} else { | 		} else { | ||||||
| 			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); | 			items.value = items.value.concat(res) ; | ||||||
| 			more.value = false; | 			more.value = false; | ||||||
| 		} | 		} | ||||||
| 		offset.value += res.length; | 		offset.value += res.length; | ||||||
|  | @ -182,99 +289,91 @@ const fetchMoreAhead = async (): Promise<void> => { | ||||||
| 	}); | 	}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const prepend = (item: Item): void => { | const prepend = (item: MisskeyEntity): void => { | ||||||
| 	if (props.pagination.reversed) { | 	// 初回表示時はunshiftだけでOK | ||||||
| 		if (rootEl.value) { | 	if (!rootEl) { | ||||||
| 			const container = getScrollContainer(rootEl.value); | 		items.value.unshift(item); | ||||||
| 			if (container == null) return; // TODO? | 		return; | ||||||
| 
 |  | ||||||
| 			const pos = getScrollPosition(rootEl.value); |  | ||||||
| 			const viewHeight = container.clientHeight; |  | ||||||
| 			const height = container.scrollHeight; |  | ||||||
| 			const isBottom = (pos + viewHeight > height - 32); |  | ||||||
| 			if (isBottom) { |  | ||||||
| 				// オーバーフローしたら古いアイテムは捨てる |  | ||||||
| 				if (items.value.length >= props.displayLimit) { |  | ||||||
| 					// このやり方だとVue 3.2以降アニメーションが動かなくなる |  | ||||||
| 					//items.value = items.value.slice(-props.displayLimit); |  | ||||||
| 					while (items.value.length >= props.displayLimit) { |  | ||||||
| 						items.value.shift(); |  | ||||||
| 					} |  | ||||||
| 					more.value = true; |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		items.value.push(item); |  | ||||||
| 		// TODO |  | ||||||
| 	} else { |  | ||||||
| 		// 初回表示時はunshiftだけでOK |  | ||||||
| 		if (!rootEl.value) { |  | ||||||
| 			items.value.unshift(item); |  | ||||||
| 			return; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); |  | ||||||
| 
 |  | ||||||
| 		if (isTop) { |  | ||||||
| 			// Prepend the item |  | ||||||
| 			items.value.unshift(item); |  | ||||||
| 
 |  | ||||||
| 			// オーバーフローしたら古いアイテムは捨てる |  | ||||||
| 			if (items.value.length >= props.displayLimit) { |  | ||||||
| 				// このやり方だとVue 3.2以降アニメーションが動かなくなる |  | ||||||
| 				//this.items = items.value.slice(0, props.displayLimit); |  | ||||||
| 				while (items.value.length >= props.displayLimit) { |  | ||||||
| 					items.value.pop(); |  | ||||||
| 				} |  | ||||||
| 				more.value = true; |  | ||||||
| 			} |  | ||||||
| 		} else { |  | ||||||
| 			queue.value.push(item); |  | ||||||
| 			onScrollTop(rootEl.value, () => { |  | ||||||
| 				for (const item of queue.value) { |  | ||||||
| 					prepend(item); |  | ||||||
| 				} |  | ||||||
| 				queue.value = []; |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, ASOBI); | ||||||
|  | 
 | ||||||
|  | 	if (isTop) unshiftItems([item]); | ||||||
|  | 	else prependQueue(item); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const append = (item: Item): void => { | function unshiftItems(newItems: MisskeyEntity[]) { | ||||||
|  | 	const length = newItems.length + items.value.length; | ||||||
|  | 	items.value = [ ...newItems, ...items.value ].slice(0, props.displayLimit); | ||||||
|  | 
 | ||||||
|  | 	if (length >= props.displayLimit) more.value = true; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function executeQueue() { | ||||||
|  | 	if (queue.value.length === 0) return; | ||||||
|  | 	unshiftItems(queue.value); | ||||||
|  | 	queue.value = []; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function prependQueue(newItem: MisskeyEntity) { | ||||||
|  | 	queue.value.unshift(newItem); | ||||||
|  | 	if (queue.value.length >= props.displayLimit) { | ||||||
|  | 		queue.value.pop(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const appendItem = (item: MisskeyEntity): void => { | ||||||
| 	items.value.push(item); | 	items.value.push(item); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => { | const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { | ||||||
| 	const i = items.value.findIndex(item => item.id === id); | 	const i = items.value.findIndex(item => item.id === id); | ||||||
| 	items.value[i] = replacer(items.value[i]); | 	items.value[i] = replacer(items.value[i]); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| if (props.pagination.params && isRef(props.pagination.params)) { | const inited = init(); | ||||||
| 	watch(props.pagination.params, init, { deep: true }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| watch(queue, (a, b) => { |  | ||||||
| 	if (a.length === 0 && b.length === 0) return; |  | ||||||
| 	emit('queue', queue.value.length); |  | ||||||
| }, { deep: true }); |  | ||||||
| 
 |  | ||||||
| init(); |  | ||||||
| 
 | 
 | ||||||
| onActivated(() => { | onActivated(() => { | ||||||
| 	isBackTop.value = false; | 	isBackTop.value = false; | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| onDeactivated(() => { | onDeactivated(() => { | ||||||
| 	isBackTop.value = window.scrollY === 0; | 	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); | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | onBeforeUnmount(() => { | ||||||
|  | 	observer.disconnect(); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| defineExpose({ | defineExpose({ | ||||||
| 	items, | 	items, | ||||||
| 	backed, | 	backed, | ||||||
|  | 	more, | ||||||
|  | 	inited, | ||||||
| 	reload, | 	reload, | ||||||
| 	fetchMoreAhead, | 	fetchMoreAhead, | ||||||
| 	prepend, | 	prepend, | ||||||
| 	append, | 	append: appendItem, | ||||||
| 	updateItem, | 	updateItem, | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | @ -62,10 +62,6 @@ function dragClear(fn) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
| 	provide: { |  | ||||||
| 		inWindow: true |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	props: { | 	props: { | ||||||
| 		padding: { | 		padding: { | ||||||
| 			type: Boolean, | 			type: Boolean, | ||||||
|  |  | ||||||
|  | @ -7,8 +7,8 @@ | ||||||
| 		</div> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
| 
 | 
 | ||||||
| 	<template #default="{ items: users }"> | 	<template #default="{ items: users, itemsContainer }"> | ||||||
| 		<div class="efvhhmdq"> | 		<div class="efvhhmdq" ref="itemsContainer"> | ||||||
| 			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/> | 			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/> | ||||||
| 		</div> | 		</div> | ||||||
| 	</template> | 	</template> | ||||||
|  |  | ||||||
|  | @ -5,8 +5,10 @@ export default { | ||||||
| 		//const query = binding.value;
 | 		//const query = binding.value;
 | ||||||
| 
 | 
 | ||||||
| 		const header = src.children[0]; | 		const header = src.children[0]; | ||||||
|  | 		const body = src.children[1]; | ||||||
| 		const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; | 		const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; | ||||||
| 		src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); | 		src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); | ||||||
|  | 		if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString(); | ||||||
| 		header.style.setProperty('--stickyTop', currentStickyTop); | 		header.style.setProperty('--stickyTop', currentStickyTop); | ||||||
| 		header.style.position = 'sticky'; | 		header.style.position = 'sticky'; | ||||||
| 		header.style.top = 'var(--stickyTop)'; | 		header.style.top = 'var(--stickyTop)'; | ||||||
|  |  | ||||||
|  | @ -19,8 +19,8 @@ | ||||||
| 			</div> | 			</div> | ||||||
| 			<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> | 			<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> | ||||||
| 				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template> | 				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template> | ||||||
| 				<template v-slot="{items}"> | 				<template v-slot="{items, itemsContainer}"> | ||||||
| 					<div class="ldhfsamy"> | 					<div class="ldhfsamy" ref="itemsContainer"> | ||||||
| 						<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> | 						<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> | ||||||
| 							<img :src="emoji.url" class="img" :alt="emoji.name"/> | 							<img :src="emoji.url" class="img" :alt="emoji.name"/> | ||||||
| 							<div class="body"> | 							<div class="body"> | ||||||
|  | @ -45,8 +45,8 @@ | ||||||
| 			</FormSplit> | 			</FormSplit> | ||||||
| 			<MkPagination :pagination="remotePagination"> | 			<MkPagination :pagination="remotePagination"> | ||||||
| 				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template> | 				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template> | ||||||
| 				<template v-slot="{items}"> | 				<template v-slot="{items, itemsContainer}"> | ||||||
| 					<div class="ldhfsamy"> | 					<div class="ldhfsamy" ref="itemsContainer"> | ||||||
| 						<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> | 						<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> | ||||||
| 							<img :src="emoji.url" class="img" :alt="emoji.name"/> | 							<img :src="emoji.url" class="img" :alt="emoji.name"/> | ||||||
| 							<div class="body"> | 							<div class="body"> | ||||||
|  |  | ||||||
|  | @ -8,8 +8,8 @@ | ||||||
| 			</div> | 			</div> | ||||||
| 		</template> | 		</template> | ||||||
| 
 | 
 | ||||||
| 		<template #default="{ items }"> | 		<template #default="{ items, itemsContainerWrapped }"> | ||||||
| 			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> | 			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false" v-model:itemsContainer="itemsContainerWrapped.v.value"> | ||||||
| 				<XNote :key="item.id" :note="item.note" :class="$style.note"/> | 				<XNote :key="item.id" :note="item.note" :class="$style.note"/> | ||||||
| 			</XList> | 			</XList> | ||||||
| 		</template> | 		</template> | ||||||
|  |  | ||||||
|  | @ -41,8 +41,8 @@ | ||||||
| 			</FormSplit> | 			</FormSplit> | ||||||
| 		</div> | 		</div> | ||||||
| 
 | 
 | ||||||
| 		<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> | 		<MkPagination v-slot="{items, itemsContainer}" ref="instances" :key="host + state" :pagination="pagination"> | ||||||
| 			<div class="dqokceoi"> | 			<div class="dqokceoi" ref="itemsContainer"> | ||||||
| 				<MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`"> | 				<MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`"> | ||||||
| 					<div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div> | 					<div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div> | ||||||
| 					<div class="table"> | 					<div class="table"> | ||||||
|  |  | ||||||
|  | @ -7,8 +7,8 @@ | ||||||
| 				<div>{{ $ts.noFollowRequests }}</div> | 				<div>{{ $ts.noFollowRequests }}</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</template> | 		</template> | ||||||
| 		<template v-slot="{items}"> | 		<template v-slot="{items, itemsContainer}"> | ||||||
| 			<div class="mk-follow-requests"> | 			<div class="mk-follow-requests" ref="itemsContainer"> | ||||||
| 				<div v-for="req in items" :key="req.id" class="user _panel"> | 				<div v-for="req in items" :key="req.id" class="user _panel"> | ||||||
| 					<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> | 					<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> | ||||||
| 					<div class="body"> | 					<div class="body"> | ||||||
|  |  | ||||||
|  | @ -9,32 +9,32 @@ | ||||||
| 	<div v-if="tab === 'explore'"> | 	<div v-if="tab === 'explore'"> | ||||||
| 		<MkFolder class="_gap"> | 		<MkFolder class="_gap"> | ||||||
| 			<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> | 			<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> | ||||||
| 			<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> | 			<MkPagination v-slot="{items, itemsContainer}" :pagination="recentPostsPagination" :disable-auto-load="true"> | ||||||
| 				<div class="vfpdbgtk"> | 				<div class="vfpdbgtk" ref="itemsContainer"> | ||||||
| 					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | 					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | ||||||
| 				</div> | 				</div> | ||||||
| 			</MkPagination> | 			</MkPagination> | ||||||
| 		</MkFolder> | 		</MkFolder> | ||||||
| 		<MkFolder class="_gap"> | 		<MkFolder class="_gap"> | ||||||
| 			<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> | 			<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> | ||||||
| 			<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> | 			<MkPagination v-slot="{items, itemsContainer}" :pagination="popularPostsPagination" :disable-auto-load="true"> | ||||||
| 				<div class="vfpdbgtk"> | 				<div class="vfpdbgtk" ref="itemsContainer"> | ||||||
| 					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | 					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | ||||||
| 				</div> | 				</div> | ||||||
| 			</MkPagination> | 			</MkPagination> | ||||||
| 		</MkFolder> | 		</MkFolder> | ||||||
| 	</div> | 	</div> | ||||||
| 	<div v-else-if="tab === 'liked'"> | 	<div v-else-if="tab === 'liked'"> | ||||||
| 		<MkPagination v-slot="{items}" :pagination="likedPostsPagination"> | 		<MkPagination v-slot="{items, itemsContainer}" :pagination="likedPostsPagination"> | ||||||
| 			<div class="vfpdbgtk"> | 			<div class="vfpdbgtk" ref="itemsContainer"> | ||||||
| 				<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> | 				<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> | ||||||
| 			</div> | 			</div> | ||||||
| 		</MkPagination> | 		</MkPagination> | ||||||
| 	</div> | 	</div> | ||||||
| 	<div v-else-if="tab === 'my'"> | 	<div v-else-if="tab === 'my'"> | ||||||
| 		<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> | 		<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> | ||||||
| 		<MkPagination v-slot="{items}" :pagination="myPostsPagination"> | 		<MkPagination v-slot="{items, itemsContainer}" :pagination="myPostsPagination"> | ||||||
| 			<div class="vfpdbgtk"> | 			<div class="vfpdbgtk" ref="itemsContainer"> | ||||||
| 				<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | 				<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | ||||||
| 			</div> | 			</div> | ||||||
| 		</MkPagination> | 		</MkPagination> | ||||||
|  |  | ||||||
|  | @ -36,8 +36,8 @@ | ||||||
| 			<MkAd :prefer="['horizontal', 'horizontal-big']"/> | 			<MkAd :prefer="['horizontal', 'horizontal-big']"/> | ||||||
| 			<MkContainer :max-height="300" :foldable="true" class="other"> | 			<MkContainer :max-height="300" :foldable="true" class="other"> | ||||||
| 				<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> | 				<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> | ||||||
| 				<MkPagination v-slot="{items}" :pagination="otherPostsPagination"> | 				<MkPagination v-slot="{items, itemsContainer}" :pagination="otherPostsPagination"> | ||||||
| 					<div class="sdrarzaf"> | 					<div class="sdrarzaf" ref="itemsContainer"> | ||||||
| 						<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | 						<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | ||||||
| 					</div> | 					</div> | ||||||
| 				</MkPagination> | 				</MkPagination> | ||||||
|  |  | ||||||
|  | @ -4,219 +4,216 @@ | ||||||
| 	@drop.stop="onDrop" | 	@drop.stop="onDrop" | ||||||
| > | > | ||||||
| 	<textarea | 	<textarea | ||||||
| 		ref="text" | 		ref="textEl" | ||||||
| 		v-model="text" | 		v-model="text" | ||||||
| 		:placeholder="$ts.inputMessageHere" | 		:placeholder="i18n.ts.inputMessageHere" | ||||||
| 		@keydown="onKeydown" | 		@keydown="onKeydown" | ||||||
| 		@compositionupdate="onCompositionUpdate" | 		@compositionupdate="onCompositionUpdate" | ||||||
| 		@paste="onPaste" | 		@paste="onPaste" | ||||||
| 	></textarea> | 	></textarea> | ||||||
| 	<div v-if="file" class="file" @click="file = null">{{ file.name }}</div> | 	<footer> | ||||||
| 	<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send"> | 		<div v-if="file" class="file" @click="file = null">{{ file.name }}</div> | ||||||
| 		<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> | 		<div class="buttons"> | ||||||
| 	</button> | 			<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> | ||||||
| 	<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> | 			<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> | ||||||
| 	<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> | 			<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> | ||||||
| 	<input ref="file" type="file" @change="onChangeFile"/> | 				<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> | ||||||
|  | 			</button> | ||||||
|  | 		</div> | ||||||
|  | 	</footer> | ||||||
|  | 	<input ref="fileEl" type="file" @change="onChangeFile"/> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineComponent, defineAsyncComponent } from 'vue'; | import { onMounted, watch } from 'vue'; | ||||||
| import insertTextAtCursor from 'insert-text-at-cursor'; | import * as Misskey from 'misskey-js'; | ||||||
| import autosize from 'autosize'; | import autosize from 'autosize'; | ||||||
|  | import insertTextAtCursor from 'insert-text-at-cursor'; | ||||||
| import { formatTimeString } from '@/scripts/format-time-string'; | import { formatTimeString } from '@/scripts/format-time-string'; | ||||||
| import { selectFile } from '@/scripts/select-file'; | import { selectFile } from '@/scripts/select-file'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { stream } from '@/stream'; | import { stream } from '@/stream'; | ||||||
| import { Autocomplete } from '@/scripts/autocomplete'; |  | ||||||
| import { throttle } from 'throttle-debounce'; | import { throttle } from 'throttle-debounce'; | ||||||
|  | import { defaultStore } from '@/store'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
|  | import { Autocomplete } from '@/scripts/autocomplete'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | const props = defineProps<{ | ||||||
| 	props: { | 	user?: Misskey.entities.UserDetailed | null; | ||||||
| 		user: { | 	group?: Misskey.entities.UserGroup | null; | ||||||
| 			type: Object, | }>(); | ||||||
| 			requird: false, | 
 | ||||||
| 		}, | let textEl = $ref<HTMLTextAreaElement>(); | ||||||
| 		group: { | let fileEl = $ref<HTMLInputElement>(); | ||||||
| 			type: Object, | 
 | ||||||
| 			requird: false, | let text: string = $ref(''); | ||||||
| 		}, | let file: Misskey.entities.DriveFile | null = $ref(null); | ||||||
| 	}, | let sending = $ref(false); | ||||||
| 	data() { | const typing = throttle(3000, () => { | ||||||
| 		return { | 	stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id }); | ||||||
| 			text: null, | }); | ||||||
| 			file: null, | 
 | ||||||
| 			sending: false, | let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id); | ||||||
| 			typing: throttle(3000, () => { | let canSend = $computed(() => (text != null && text != '') || file != null); | ||||||
| 				stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); | 
 | ||||||
| 			}), | watch([$$(text), $$(file)], saveDraft); | ||||||
| 		}; | 
 | ||||||
| 	}, | async function onPaste(e: ClipboardEvent) { | ||||||
| 	computed: { | 	if (!e.clipboardData) return; | ||||||
| 		draftKey(): string { | 
 | ||||||
| 			return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; | 	const data = e.clipboardData; | ||||||
| 		}, | 	const items = data.items; | ||||||
| 		canSend(): boolean { | 
 | ||||||
| 			return (this.text != null && this.text != '') || this.file != null; | 	if (items.length == 1) { | ||||||
| 		}, | 		if (items[0].kind == 'file') { | ||||||
| 		room(): any { | 			const file = items[0].getAsFile(); | ||||||
| 			return this.$parent; | 			if (!file) return; | ||||||
|  | 			const lio = file.name.lastIndexOf('.'); | ||||||
|  | 			const ext = lio >= 0 ? file.name.slice(lio) : ''; | ||||||
|  | 			const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; | ||||||
|  | 			if (formatted) upload(file, formatted); | ||||||
| 		} | 		} | ||||||
| 	}, | 	} else { | ||||||
| 	watch: { | 		if (items[0].kind == 'file') { | ||||||
| 		text() { | 			os.alert({ | ||||||
| 			this.saveDraft(); | 				type: 'error', | ||||||
| 		}, | 				text: i18n.ts.onlyOneFileCanBeAttached | ||||||
| 		file() { |  | ||||||
| 			this.saveDraft(); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 	mounted() { |  | ||||||
| 		autosize(this.$refs.text); |  | ||||||
| 
 |  | ||||||
| 		// TODO: detach when unmount |  | ||||||
| 		// TODO |  | ||||||
| 		//new Autocomplete(this.$refs.text, this, { model: 'text' }); |  | ||||||
| 
 |  | ||||||
| 		// 書きかけの投稿を復元 |  | ||||||
| 		const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; |  | ||||||
| 		if (draft) { |  | ||||||
| 			this.text = draft.data.text; |  | ||||||
| 			this.file = draft.data.file; |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 	methods: { |  | ||||||
| 		async onPaste(e: ClipboardEvent) { |  | ||||||
| 			const data = e.clipboardData; |  | ||||||
| 			const items = data.items; |  | ||||||
| 
 |  | ||||||
| 			if (items.length == 1) { |  | ||||||
| 				if (items[0].kind == 'file') { |  | ||||||
| 					const file = items[0].getAsFile(); |  | ||||||
| 					const lio = file.name.lastIndexOf('.'); |  | ||||||
| 					const ext = lio >= 0 ? file.name.slice(lio) : ''; |  | ||||||
| 					const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; |  | ||||||
| 					if (formatted) this.upload(file, formatted); |  | ||||||
| 				} |  | ||||||
| 			} else { |  | ||||||
| 				if (items[0].kind == 'file') { |  | ||||||
| 					os.alert({ |  | ||||||
| 						type: 'error', |  | ||||||
| 						text: this.$ts.onlyOneFileCanBeAttached |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onDragover(e) { |  | ||||||
| 			const isFile = e.dataTransfer.items[0].kind == 'file'; |  | ||||||
| 			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; |  | ||||||
| 			if (isFile || isDriveFile) { |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onDrop(e): void { |  | ||||||
| 			// ファイルだったら |  | ||||||
| 			if (e.dataTransfer.files.length == 1) { |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 				this.upload(e.dataTransfer.files[0]); |  | ||||||
| 				return; |  | ||||||
| 			} else if (e.dataTransfer.files.length > 1) { |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 				os.alert({ |  | ||||||
| 					type: 'error', |  | ||||||
| 					text: this.$ts.onlyOneFileCanBeAttached |  | ||||||
| 				}); |  | ||||||
| 				return; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			//#region ドライブのファイル |  | ||||||
| 			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); |  | ||||||
| 			if (driveFile != null && driveFile != '') { |  | ||||||
| 				this.file = JSON.parse(driveFile); |  | ||||||
| 				e.preventDefault(); |  | ||||||
| 			} |  | ||||||
| 			//#endregion |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onKeydown(e) { |  | ||||||
| 			this.typing(); |  | ||||||
| 			if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { |  | ||||||
| 				this.send(); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onCompositionUpdate() { |  | ||||||
| 			this.typing(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		chooseFile(e) { |  | ||||||
| 			selectFile(e.currentTarget ?? e.target, this.$ts.selectFile).then(file => { |  | ||||||
| 				this.file = file; |  | ||||||
| 			}); | 			}); | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onChangeFile() { |  | ||||||
| 			this.upload((this.$refs.file as any).files[0]); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		upload(file: File, name?: string) { |  | ||||||
| 			os.upload(file, this.$store.state.uploadFolder, name).then(res => { |  | ||||||
| 				this.file = res; |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		send() { |  | ||||||
| 			this.sending = true; |  | ||||||
| 			os.api('messaging/messages/create', { |  | ||||||
| 				userId: this.user ? this.user.id : undefined, |  | ||||||
| 				groupId: this.group ? this.group.id : undefined, |  | ||||||
| 				text: this.text ? this.text : undefined, |  | ||||||
| 				fileId: this.file ? this.file.id : undefined |  | ||||||
| 			}).then(message => { |  | ||||||
| 				this.clear(); |  | ||||||
| 			}).catch(err => { |  | ||||||
| 				console.error(err); |  | ||||||
| 			}).then(() => { |  | ||||||
| 				this.sending = false; |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		clear() { |  | ||||||
| 			this.text = ''; |  | ||||||
| 			this.file = null; |  | ||||||
| 			this.deleteDraft(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		saveDraft() { |  | ||||||
| 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); |  | ||||||
| 
 |  | ||||||
| 			data[this.draftKey] = { |  | ||||||
| 				updatedAt: new Date(), |  | ||||||
| 				data: { |  | ||||||
| 					text: this.text, |  | ||||||
| 					file: this.file |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			localStorage.setItem('message_drafts', JSON.stringify(data)); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		deleteDraft() { |  | ||||||
| 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); |  | ||||||
| 
 |  | ||||||
| 			delete data[this.draftKey]; |  | ||||||
| 
 |  | ||||||
| 			localStorage.setItem('message_drafts', JSON.stringify(data)); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		async insertEmoji(ev) { |  | ||||||
| 			os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onDragover(e: DragEvent) { | ||||||
|  | 	if (!e.dataTransfer) return; | ||||||
|  | 
 | ||||||
|  | 	const isFile = e.dataTransfer.items[0].kind == 'file'; | ||||||
|  | 	const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; | ||||||
|  | 	if (isFile || isDriveFile) { | ||||||
|  | 		e.preventDefault(); | ||||||
|  | 		e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onDrop(e: DragEvent): void { | ||||||
|  | 	if (!e.dataTransfer) return; | ||||||
|  | 
 | ||||||
|  | 	// ファイルだったら | ||||||
|  | 	if (e.dataTransfer.files.length == 1) { | ||||||
|  | 		e.preventDefault(); | ||||||
|  | 		upload(e.dataTransfer.files[0]); | ||||||
|  | 		return; | ||||||
|  | 	} else if (e.dataTransfer.files.length > 1) { | ||||||
|  | 		e.preventDefault(); | ||||||
|  | 		os.alert({ | ||||||
|  | 			type: 'error', | ||||||
|  | 			text: i18n.ts.onlyOneFileCanBeAttached | ||||||
|  | 		}); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	//#region ドライブのファイル | ||||||
|  | 	const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); | ||||||
|  | 	if (driveFile != null && driveFile != '') { | ||||||
|  | 		file = JSON.parse(driveFile); | ||||||
|  | 		e.preventDefault(); | ||||||
|  | 	} | ||||||
|  | 	//#endregion | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onKeydown(e: KeyboardEvent) { | ||||||
|  | 	typing(); | ||||||
|  | 	if ((e.key === 'Enter') && (e.ctrlKey || e.metaKey) && canSend) { | ||||||
|  | 		send(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onCompositionUpdate() { | ||||||
|  | 	typing(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function chooseFile(e: MouseEvent) { | ||||||
|  | 	selectFile(e.currentTarget ?? e.target, i18n.ts.selectFile).then(selectedFile => { | ||||||
|  | 		file = selectedFile; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onChangeFile() { | ||||||
|  | 	if (fileEl?.files![0]) upload(fileEl.files[0]); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function upload(fileToUpload: File, name?: string) { | ||||||
|  | 	os.upload(fileToUpload, defaultStore.state.uploadFolder, name).then(res => { | ||||||
|  | 		file = res; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function send() { | ||||||
|  | 	sending = true; | ||||||
|  | 	os.api('messaging/messages/create', { | ||||||
|  | 		userId: props.user ? props.user.id : undefined, | ||||||
|  | 		groupId: props.group ? props.group.id : undefined, | ||||||
|  | 		text: text ? text : undefined, | ||||||
|  | 		fileId: file ? file.id : undefined | ||||||
|  | 	}).then(message => { | ||||||
|  | 		clear(); | ||||||
|  | 	}).catch(err => { | ||||||
|  | 		console.error(err); | ||||||
|  | 	}).then(() => { | ||||||
|  | 		sending = false; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function clear() { | ||||||
|  | 	text = ''; | ||||||
|  | 	file = null; | ||||||
|  | 	deleteDraft(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function saveDraft() { | ||||||
|  | 	const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||||
|  | 
 | ||||||
|  | 	data[draftKey] = { | ||||||
|  | 		updatedAt: new Date(), | ||||||
|  | 		data: { | ||||||
|  | 			text: text, | ||||||
|  | 			file: file | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function deleteDraft() { | ||||||
|  | 	const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||||
|  | 
 | ||||||
|  | 	delete data[draftKey]; | ||||||
|  | 
 | ||||||
|  | 	localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function insertEmoji(ev: MouseEvent) { | ||||||
|  | 	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | onMounted(() => { | ||||||
|  | 	autosize(textEl); | ||||||
|  | 
 | ||||||
|  | 	// TODO: detach when unmount | ||||||
|  | 	// TODO | ||||||
|  | 	//new Autocomplete(textEl, this, { model: 'text' }); | ||||||
|  | 
 | ||||||
|  | 	// 書きかけの投稿を復元 | ||||||
|  | 	const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey]; | ||||||
|  | 	if (draft) { | ||||||
|  | 		text = draft.data.text; | ||||||
|  | 		file = draft.data.file; | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | defineExpose({ | ||||||
|  | 	file, | ||||||
|  | 	upload, | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  | @ -230,7 +227,7 @@ export default defineComponent({ | ||||||
| 		width: 100%; | 		width: 100%; | ||||||
| 		min-width: 100%; | 		min-width: 100%; | ||||||
| 		max-width: 100%; | 		max-width: 100%; | ||||||
| 		height: 80px; | 		min-height: 80px; | ||||||
| 		margin: 0; | 		margin: 0; | ||||||
| 		padding: 16px 16px 0 16px; | 		padding: 16px 16px 0 16px; | ||||||
| 		resize: none; | 		resize: none; | ||||||
|  | @ -245,26 +242,16 @@ export default defineComponent({ | ||||||
| 		color: var(--fg); | 		color: var(--fg); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	> .file { | 	footer { | ||||||
| 		padding: 8px; | 		position: sticky; | ||||||
| 		color: #444; |  | ||||||
| 		background: #eee; |  | ||||||
| 		cursor: pointer; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	> .send { |  | ||||||
| 		position: absolute; |  | ||||||
| 		bottom: 0; | 		bottom: 0; | ||||||
| 		right: 0; | 		background: var(--panel); | ||||||
| 		margin: 0; |  | ||||||
| 		padding: 16px; |  | ||||||
| 		font-size: 1em; |  | ||||||
| 		transition: color 0.1s ease; |  | ||||||
| 		color: var(--accent); |  | ||||||
| 
 | 
 | ||||||
| 		&:active { | 		> .file { | ||||||
| 			color: var(--accentDarken); | 			padding: 8px; | ||||||
| 			transition: color 0s ease; | 			color: var(--fg); | ||||||
|  | 			background: transparent; | ||||||
|  | 			cursor: pointer; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -316,21 +303,39 @@ export default defineComponent({ | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	._button { | 	.buttons { | ||||||
| 		margin: 0; | 		display: flex; | ||||||
| 		padding: 16px; |  | ||||||
| 		font-size: 1em; |  | ||||||
| 		font-weight: normal; |  | ||||||
| 		text-decoration: none; |  | ||||||
| 		transition: color 0.1s ease; |  | ||||||
| 
 | 
 | ||||||
| 		&:hover { | 		._button { | ||||||
| 			color: var(--accent); | 			margin: 0; | ||||||
|  | 			padding: 16px; | ||||||
|  | 			font-size: 1em; | ||||||
|  | 			font-weight: normal; | ||||||
|  | 			text-decoration: none; | ||||||
|  | 			transition: color 0.1s ease; | ||||||
|  | 
 | ||||||
|  | 			&:hover { | ||||||
|  | 				color: var(--accent); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			&:active { | ||||||
|  | 				color: var(--accentDarken); | ||||||
|  | 				transition: color 0s ease; | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		&:active { | 		> .send { | ||||||
| 			color: var(--accentDarken); | 			margin-left: auto; | ||||||
| 			transition: color 0s ease; | 			color: var(--accent); | ||||||
|  | 
 | ||||||
|  | 			&:hover { | ||||||
|  | 				color: var(--accentLighten); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			&:active { | ||||||
|  | 				color: var(--accentDarken); | ||||||
|  | 				transition: color 0s ease; | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -35,45 +35,28 @@ | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { defineComponent } from 'vue'; | import { } from 'vue'; | ||||||
| import * as mfm from 'mfm-js'; | import * as mfm from 'mfm-js'; | ||||||
|  | import * as Misskey from 'misskey-js'; | ||||||
| import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | ||||||
| import MkUrlPreview from '@/components/url-preview.vue'; | import MkUrlPreview from '@/components/url-preview.vue'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
|  | import { $i } from '@/account'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | const props = defineProps<{ | ||||||
| 	components: { | 	message: Misskey.entities.MessagingMessage; | ||||||
| 		MkUrlPreview | 	isGroup?: boolean; | ||||||
| 	}, | }>(); | ||||||
| 	props: { | 
 | ||||||
| 		message: { | const isMe = $computed(() => props.message.userId === $i?.id); | ||||||
| 			required: true | const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); | ||||||
| 		}, | 
 | ||||||
| 		isGroup: { | function del() { | ||||||
| 			required: false | 	os.api('messaging/messages/delete', { | ||||||
| 		} | 		messageId: props.message.id | ||||||
| 	}, | 	}); | ||||||
| 	computed: { | } | ||||||
| 		isMe(): boolean { |  | ||||||
| 			return this.message.userId === this.$i.id; |  | ||||||
| 		}, |  | ||||||
| 		urls(): string[] { |  | ||||||
| 			if (this.message.text) { |  | ||||||
| 				return extractUrlFromMfm(mfm.parse(this.message.text)); |  | ||||||
| 			} else { |  | ||||||
| 				return []; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 	methods: { |  | ||||||
| 		del() { |  | ||||||
| 			os.api('messaging/messages/delete', { |  | ||||||
| 				messageId: this.message.id |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
|  | @ -266,6 +249,7 @@ export default defineComponent({ | ||||||
| 	&.isMe { | 	&.isMe { | ||||||
| 		flex-direction: row-reverse; | 		flex-direction: row-reverse; | ||||||
| 		padding-right: var(--margin); | 		padding-right: var(--margin); | ||||||
|  | 		right: var(--margin); // 削除時にposition: absoluteになったときに使う | ||||||
| 
 | 
 | ||||||
| 		> .content { | 		> .content { | ||||||
| 			padding-right: 16px; | 			padding-right: 16px; | ||||||
|  |  | ||||||
|  | @ -2,378 +2,304 @@ | ||||||
| <div class="_section" | <div class="_section" | ||||||
| 	@dragover.prevent.stop="onDragover" | 	@dragover.prevent.stop="onDragover" | ||||||
| 	@drop.prevent.stop="onDrop" | 	@drop.prevent.stop="onDrop" | ||||||
|  | 	ref="rootEl" | ||||||
| > | > | ||||||
| 	<div class="_content mk-messaging-room"> | 	<div class="_content mk-messaging-room"> | ||||||
| 		<div class="body"> | 		<div class="body"> | ||||||
| 			<MkLoading v-if="fetching"/> | 			<MkPagination ref="pagingComponent" :key="userAcct || groupId" v-if="pagination" :pagination="pagination"> | ||||||
| 			<p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p> | 				<template #empty> | ||||||
| 			<p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p> | 					<div class="_fullinfo"> | ||||||
| 			<button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages"> | 						<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> | ||||||
| 				<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }} | 						<div>{{ i18n.ts.noMessagesYet }}</div> | ||||||
| 			</button> | 					</div> | ||||||
| 			<XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed> | 				</template> | ||||||
| 				<XMessage :key="message.id" :message="message" :is-group="group != null"/> | 
 | ||||||
| 			</XList> | 				<template #default="{ items: messages, fetching, itemsContainerWrapped }"> | ||||||
|  | 					<XList | ||||||
|  | 						v-if="messages.length > 0" | ||||||
|  | 						v-slot="{ item: message }" | ||||||
|  | 						:class="{ messages: true, 'deny-move-transition': fetching }" | ||||||
|  | 						:items="messages" | ||||||
|  | 						v-model:itemsContainer="itemsContainerWrapped.v.value" | ||||||
|  | 						direction="up" | ||||||
|  | 						reversed | ||||||
|  | 					> | ||||||
|  | 						<XMessage :key="message.id" :message="message" :is-group="group != null"/> | ||||||
|  | 					</XList> | ||||||
|  | 				</template> | ||||||
|  | 			</MkPagination> | ||||||
| 		</div> | 		</div> | ||||||
| 		<footer> | 		<footer> | ||||||
| 			<div v-if="typers.length > 0" class="typers"> | 			<div v-if="typers.length > 0" class="typers"> | ||||||
| 				<I18n :src="$ts.typingUsers" text-tag="span" class="users"> | 				<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> | ||||||
| 					<template #users> | 					<template #users> | ||||||
| 						<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> | 						<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> | ||||||
| 					</template> | 					</template> | ||||||
| 				</I18n> | 				</I18n> | ||||||
| 				<MkEllipsis/> | 				<MkEllipsis/> | ||||||
| 			</div> | 			</div> | ||||||
| 			<transition :name="$store.state.animation ? 'fade' : ''"> | 			<transition :name="animation ? 'fade' : ''"> | ||||||
| 				<div v-show="showIndicator" class="new-message"> | 				<div class="new-message" v-show="showIndicator"> | ||||||
| 					<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button> | 					<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> | ||||||
| 				</div> | 				</div> | ||||||
| 			</transition> | 			</transition> | ||||||
| 			<XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/> | 			<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> | ||||||
| 		</footer> | 		</footer> | ||||||
| 	</div> | 	</div> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts" setup> | ||||||
| import { computed, defineComponent, markRaw } from 'vue'; | import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'; | ||||||
|  | import * as Misskey from 'misskey-js'; | ||||||
|  | import * as Acct from 'misskey-js/built/acct'; | ||||||
| import XList from '@/components/date-separated-list.vue'; | import XList from '@/components/date-separated-list.vue'; | ||||||
|  | import MkPagination, { Paging } from '@/components/ui/pagination.vue'; | ||||||
| import XMessage from './messaging-room.message.vue'; | import XMessage from './messaging-room.message.vue'; | ||||||
| import XForm from './messaging-room.form.vue'; | import XForm from './messaging-room.form.vue'; | ||||||
| import * as Acct from 'misskey-js/built/acct'; | import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; | ||||||
| import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; |  | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { stream } from '@/stream'; | import { stream } from '@/stream'; | ||||||
| import { popout } from '@/scripts/popout'; |  | ||||||
| import * as sound from '@/scripts/sound'; | import * as sound from '@/scripts/sound'; | ||||||
| import * as symbols from '@/symbols'; | import * as symbols from '@/symbols'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
|  | import { $i } from '@/account'; | ||||||
|  | import { defaultStore } from '@/store'; | ||||||
| 
 | 
 | ||||||
| const Component = defineComponent({ | const props = defineProps<{ | ||||||
| 	components: { | 	userAcct?: string; | ||||||
| 		XMessage, | 	groupId?: string; | ||||||
| 		XForm, | }>(); | ||||||
| 		XList, |  | ||||||
| 	}, |  | ||||||
| 
 | 
 | ||||||
| 	inject: ['inWindow'], | let rootEl = $ref<HTMLDivElement>(); | ||||||
|  | let formEl = $ref<InstanceType<typeof XForm>>(); | ||||||
|  | let pagingComponent = $ref<InstanceType<typeof MkPagination>>(); | ||||||
| 
 | 
 | ||||||
| 	props: { | let fetching = $ref(true); | ||||||
| 		userAcct: { | let user: Misskey.entities.UserDetailed | null = $ref(null); | ||||||
| 			type: String, | let group: Misskey.entities.UserGroup | null = $ref(null); | ||||||
| 			required: false, | let typers: Misskey.entities.User[] = $ref([]); | ||||||
| 		}, | let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null); | ||||||
| 		groupId: { | let showIndicator = $ref(false); | ||||||
| 			type: String, | const { | ||||||
| 			required: false, | 	animation | ||||||
| 		}, | } = defaultStore.reactiveState; | ||||||
| 	}, |  | ||||||
| 
 | 
 | ||||||
| 	data() { | let pagination: Paging | null = $ref(null); | ||||||
| 		return { |  | ||||||
| 			[symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? { |  | ||||||
| 				userName: this.user, |  | ||||||
| 				avatar: this.user, |  | ||||||
| 				action: { |  | ||||||
| 					icon: 'fas fa-ellipsis-h', |  | ||||||
| 					handler: this.menu, |  | ||||||
| 				}, |  | ||||||
| 			} : { |  | ||||||
| 				title: this.group.name, |  | ||||||
| 				icon: 'fas fa-users', |  | ||||||
| 				action: { |  | ||||||
| 					icon: 'fas fa-ellipsis-h', |  | ||||||
| 					handler: this.menu, |  | ||||||
| 				}, |  | ||||||
| 			} : null), |  | ||||||
| 			fetching: true, |  | ||||||
| 			user: null, |  | ||||||
| 			group: null, |  | ||||||
| 			fetchingMoreMessages: false, |  | ||||||
| 			messages: [], |  | ||||||
| 			existMoreMessages: false, |  | ||||||
| 			connection: null, |  | ||||||
| 			showIndicator: false, |  | ||||||
| 			timer: null, |  | ||||||
| 			typers: [], |  | ||||||
| 			ilObserver: new IntersectionObserver( |  | ||||||
| 				(entries) => entries.some((entry) => entry.isIntersecting) |  | ||||||
| 					&& !this.fetching |  | ||||||
| 					&& !this.fetchingMoreMessages |  | ||||||
| 					&& this.existMoreMessages |  | ||||||
| 					&& this.fetchMoreMessages() |  | ||||||
| 			), |  | ||||||
| 		}; |  | ||||||
| 	}, |  | ||||||
| 
 | 
 | ||||||
| 	computed: { | watch([() => props.userAcct, () => props.groupId], () => { | ||||||
| 		form(): any { | 	if (connection) connection.dispose(); | ||||||
| 			return this.$refs.form; | 	fetch(); | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	watch: { |  | ||||||
| 		userAcct: 'fetch', |  | ||||||
| 		groupId: 'fetch', |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	mounted() { |  | ||||||
| 		this.fetch(); |  | ||||||
| 		if (this.$store.state.enableInfiniteScroll) { |  | ||||||
| 			this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element)); |  | ||||||
| 		} |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	beforeUnmount() { |  | ||||||
| 		this.connection.dispose(); |  | ||||||
| 
 |  | ||||||
| 		document.removeEventListener('visibilitychange', this.onVisibilitychange); |  | ||||||
| 
 |  | ||||||
| 		this.ilObserver.disconnect(); |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	methods: { |  | ||||||
| 		async fetch() { |  | ||||||
| 			this.fetching = true; |  | ||||||
| 			if (this.userAcct) { |  | ||||||
| 				const user = await os.api('users/show', Acct.parse(this.userAcct)); |  | ||||||
| 				this.user = user; |  | ||||||
| 			} else { |  | ||||||
| 				const group = await os.api('users/groups/show', { groupId: this.groupId }); |  | ||||||
| 				this.group = group; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			this.connection = markRaw(stream.useChannel('messaging', { |  | ||||||
| 				otherparty: this.user ? this.user.id : undefined, |  | ||||||
| 				group: this.group ? this.group.id : undefined, |  | ||||||
| 			})); |  | ||||||
| 
 |  | ||||||
| 			this.connection.on('message', this.onMessage); |  | ||||||
| 			this.connection.on('read', this.onRead); |  | ||||||
| 			this.connection.on('deleted', this.onDeleted); |  | ||||||
| 			this.connection.on('typers', typers => { |  | ||||||
| 				this.typers = typers.filter(u => u.id !== this.$i.id); |  | ||||||
| 			}); |  | ||||||
| 
 |  | ||||||
| 			document.addEventListener('visibilitychange', this.onVisibilitychange); |  | ||||||
| 
 |  | ||||||
| 			this.fetchMessages().then(() => { |  | ||||||
| 				this.scrollToBottom(); |  | ||||||
| 
 |  | ||||||
| 				// もっと見るの交差検知を発火させないためにfetchは |  | ||||||
| 				// スクロールが終わるまでfalseにしておく |  | ||||||
| 				// scrollendのようなイベントはないのでsetTimeoutで |  | ||||||
| 				window.setTimeout(() => this.fetching = false, 300); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onDragover(e) { |  | ||||||
| 			const isFile = e.dataTransfer.items[0].kind == 'file'; |  | ||||||
| 			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; |  | ||||||
| 
 |  | ||||||
| 			if (isFile || isDriveFile) { |  | ||||||
| 				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; |  | ||||||
| 			} else { |  | ||||||
| 				e.dataTransfer.dropEffect = 'none'; |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onDrop(e): void { |  | ||||||
| 			// ファイルだったら |  | ||||||
| 			if (e.dataTransfer.files.length == 1) { |  | ||||||
| 				this.form.upload(e.dataTransfer.files[0]); |  | ||||||
| 				return; |  | ||||||
| 			} else if (e.dataTransfer.files.length > 1) { |  | ||||||
| 				os.alert({ |  | ||||||
| 					type: 'error', |  | ||||||
| 					text: this.$ts.onlyOneFileCanBeAttached |  | ||||||
| 				}); |  | ||||||
| 				return; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			//#region ドライブのファイル |  | ||||||
| 			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); |  | ||||||
| 			if (driveFile != null && driveFile != '') { |  | ||||||
| 				const file = JSON.parse(driveFile); |  | ||||||
| 				this.form.file = file; |  | ||||||
| 			} |  | ||||||
| 			//#endregion |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		fetchMessages() { |  | ||||||
| 			return new Promise((resolve, reject) => { |  | ||||||
| 				const max = this.existMoreMessages ? 20 : 10; |  | ||||||
| 
 |  | ||||||
| 				os.api('messaging/messages', { |  | ||||||
| 					userId: this.user ? this.user.id : undefined, |  | ||||||
| 					groupId: this.group ? this.group.id : undefined, |  | ||||||
| 					limit: max + 1, |  | ||||||
| 					untilId: this.existMoreMessages ? this.messages[0].id : undefined |  | ||||||
| 				}).then(messages => { |  | ||||||
| 					if (messages.length == max + 1) { |  | ||||||
| 						this.existMoreMessages = true; |  | ||||||
| 						messages.pop(); |  | ||||||
| 					} else { |  | ||||||
| 						this.existMoreMessages = false; |  | ||||||
| 					} |  | ||||||
| 
 |  | ||||||
| 					this.messages.unshift.apply(this.messages, messages.reverse()); |  | ||||||
| 					resolve(); |  | ||||||
| 				}); |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		fetchMoreMessages() { |  | ||||||
| 			this.fetchingMoreMessages = true; |  | ||||||
| 			this.fetchMessages().then(() => { |  | ||||||
| 				this.fetchingMoreMessages = false; |  | ||||||
| 			}); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onMessage(message) { |  | ||||||
| 			sound.play('chat'); |  | ||||||
| 
 |  | ||||||
| 			const _isBottom = isBottom(this.$el, 64); |  | ||||||
| 
 |  | ||||||
| 			this.messages.push(message); |  | ||||||
| 			if (message.userId != this.$i.id && !document.hidden) { |  | ||||||
| 				this.connection.send('read', { |  | ||||||
| 					id: message.id |  | ||||||
| 				}); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (_isBottom) { |  | ||||||
| 				// Scroll to bottom |  | ||||||
| 				this.$nextTick(() => { |  | ||||||
| 					this.scrollToBottom(); |  | ||||||
| 				}); |  | ||||||
| 			} else if (message.userId != this.$i.id) { |  | ||||||
| 				// Notify |  | ||||||
| 				this.notifyNewMessage(); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onRead(x) { |  | ||||||
| 			if (this.user) { |  | ||||||
| 				if (!Array.isArray(x)) x = [x]; |  | ||||||
| 				for (const id of x) { |  | ||||||
| 					if (this.messages.some(x => x.id == id)) { |  | ||||||
| 						const exist = this.messages.map(x => x.id).indexOf(id); |  | ||||||
| 						this.messages[exist] = { |  | ||||||
| 							...this.messages[exist], |  | ||||||
| 							isRead: true, |  | ||||||
| 						}; |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 			} else if (this.group) { |  | ||||||
| 				for (const id of x.ids) { |  | ||||||
| 					if (this.messages.some(x => x.id == id)) { |  | ||||||
| 						const exist = this.messages.map(x => x.id).indexOf(id); |  | ||||||
| 						this.messages[exist] = { |  | ||||||
| 							...this.messages[exist], |  | ||||||
| 							reads: [...this.messages[exist].reads, x.userId] |  | ||||||
| 						}; |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onDeleted(id) { |  | ||||||
| 			const msg = this.messages.find(m => m.id === id); |  | ||||||
| 			if (msg) { |  | ||||||
| 				this.messages = this.messages.filter(m => m.id !== msg.id); |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		scrollToBottom() { |  | ||||||
| 			scroll(this.$el, { top: this.$el.offsetHeight }); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onIndicatorClick() { |  | ||||||
| 			this.showIndicator = false; |  | ||||||
| 			this.scrollToBottom(); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		notifyNewMessage() { |  | ||||||
| 			this.showIndicator = true; |  | ||||||
| 
 |  | ||||||
| 			onScrollBottom(this.$el, () => { |  | ||||||
| 				this.showIndicator = false; |  | ||||||
| 			}); |  | ||||||
| 
 |  | ||||||
| 			if (this.timer) window.clearTimeout(this.timer); |  | ||||||
| 
 |  | ||||||
| 			this.timer = window.setTimeout(() => { |  | ||||||
| 				this.showIndicator = false; |  | ||||||
| 			}, 4000); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		onVisibilitychange() { |  | ||||||
| 			if (document.hidden) return; |  | ||||||
| 			for (const message of this.messages) { |  | ||||||
| 				if (message.userId !== this.$i.id && !message.isRead) { |  | ||||||
| 					this.connection.send('read', { |  | ||||||
| 						id: message.id |  | ||||||
| 					}); |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		menu(ev) { |  | ||||||
| 			const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`; |  | ||||||
| 
 |  | ||||||
| 			os.popupMenu([this.inWindow ? undefined : { |  | ||||||
| 				text: this.$ts.openInWindow, |  | ||||||
| 				icon: 'fas fa-window-maximize', |  | ||||||
| 				action: () => { |  | ||||||
| 					os.pageWindow(path); |  | ||||||
| 					this.$router.back(); |  | ||||||
| 				}, |  | ||||||
| 			}, this.inWindow ? undefined : { |  | ||||||
| 				text: this.$ts.popout, |  | ||||||
| 				icon: 'fas fa-external-link-alt', |  | ||||||
| 				action: () => { |  | ||||||
| 					popout(path); |  | ||||||
| 					this.$router.back(); |  | ||||||
| 				}, |  | ||||||
| 			}], ev.currentTarget ?? ev.target); |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default Component; | async function fetch() { | ||||||
|  | 	fetching = true; | ||||||
|  | 
 | ||||||
|  | 	if (props.userAcct) { | ||||||
|  | 		const acct = Acct.parse(props.userAcct); | ||||||
|  | 		user = await os.api('users/show', { username: acct.username, host: acct.host || undefined }); | ||||||
|  | 		group = null; | ||||||
|  | 		 | ||||||
|  | 		pagination = { | ||||||
|  | 			endpoint: 'messaging/messages', | ||||||
|  | 			limit: 20, | ||||||
|  | 			params: { | ||||||
|  | 				userId: user.id, | ||||||
|  | 			}, | ||||||
|  | 			reversed: true, | ||||||
|  | 			pageEl: $$(rootEl).value, | ||||||
|  | 		}; | ||||||
|  | 		connection = stream.useChannel('messaging', { | ||||||
|  | 			otherparty: user.id, | ||||||
|  | 		}); | ||||||
|  | 	} else { | ||||||
|  | 		user = null; | ||||||
|  | 		group = await os.api('users/groups/show', { groupId: props.groupId }); | ||||||
|  | 
 | ||||||
|  | 		pagination = { | ||||||
|  | 			endpoint: 'messaging/messages', | ||||||
|  | 			limit: 20, | ||||||
|  | 			params: { | ||||||
|  | 				groupId: group?.id, | ||||||
|  | 			}, | ||||||
|  | 			reversed: true, | ||||||
|  | 			pageEl: $$(rootEl).value, | ||||||
|  | 		}; | ||||||
|  | 		connection = stream.useChannel('messaging', { | ||||||
|  | 			group: group?.id, | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 	connection.on('message', onMessage); | ||||||
|  | 	connection.on('read', onRead); | ||||||
|  | 	connection.on('deleted', onDeleted); | ||||||
|  | 	connection.on('typers', typers => { | ||||||
|  | 		typers = typers.filter(u => u.id !== $i?.id); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	document.addEventListener('visibilitychange', onVisibilitychange); | ||||||
|  | 
 | ||||||
|  | 	nextTick(() => { | ||||||
|  | 		pagingComponent.inited.then(() => { | ||||||
|  | 			thisScrollToBottom(); | ||||||
|  | 		}); | ||||||
|  | 		window.setTimeout(() => { | ||||||
|  | 			fetching = false | ||||||
|  | 		}, 300); | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onDragover(e: DragEvent) { | ||||||
|  | 	if (!e.dataTransfer) return; | ||||||
|  | 
 | ||||||
|  | 	const isFile = e.dataTransfer.items[0].kind == 'file'; | ||||||
|  | 	const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; | ||||||
|  | 
 | ||||||
|  | 	if (isFile || isDriveFile) { | ||||||
|  | 		e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; | ||||||
|  | 	} else { | ||||||
|  | 		e.dataTransfer.dropEffect = 'none'; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onDrop(e: DragEvent): void { | ||||||
|  | 	if (!e.dataTransfer) return; | ||||||
|  | 
 | ||||||
|  | 	// ファイルだったら | ||||||
|  | 	if (e.dataTransfer.files.length == 1) { | ||||||
|  | 		formEl.upload(e.dataTransfer.files[0]); | ||||||
|  | 		return; | ||||||
|  | 	} else if (e.dataTransfer.files.length > 1) { | ||||||
|  | 		os.alert({ | ||||||
|  | 			type: 'error', | ||||||
|  | 			text: i18n.ts.onlyOneFileCanBeAttached | ||||||
|  | 		}); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	//#region ドライブのファイル | ||||||
|  | 	const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); | ||||||
|  | 	if (driveFile != null && driveFile != '') { | ||||||
|  | 		const file = JSON.parse(driveFile); | ||||||
|  | 		formEl.file = file; | ||||||
|  | 	} | ||||||
|  | 	//#endregion | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onMessage(message) { | ||||||
|  | 	sound.play('chat'); | ||||||
|  | 
 | ||||||
|  | 	const _isBottom = isBottomVisible(rootEl, 64); | ||||||
|  | 
 | ||||||
|  | 	pagingComponent.prepend(message); | ||||||
|  | 	if (message.userId != $i?.id && !document.hidden) { | ||||||
|  | 		connection?.send('read', { | ||||||
|  | 			id: message.id | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if (_isBottom) { | ||||||
|  | 		// Scroll to bottom | ||||||
|  | 		nextTick(() => { | ||||||
|  | 			thisScrollToBottom(); | ||||||
|  | 		}); | ||||||
|  | 	} else if (message.userId != $i?.id) { | ||||||
|  | 		// Notify | ||||||
|  | 		notifyNewMessage(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onRead(x) { | ||||||
|  | 	if (user) { | ||||||
|  | 		if (!Array.isArray(x)) x = [x]; | ||||||
|  | 		for (const id of x) { | ||||||
|  | 			if (pagingComponent.items.some(x => x.id == id)) { | ||||||
|  | 				const exist = pagingComponent.items.map(x => x.id).indexOf(id); | ||||||
|  | 				pagingComponent.items[exist] = { | ||||||
|  | 					...pagingComponent.items[exist], | ||||||
|  | 					isRead: true, | ||||||
|  | 				}; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} else if (group) { | ||||||
|  | 		for (const id of x.ids) { | ||||||
|  | 			if (pagingComponent.items.some(x => x.id == id)) { | ||||||
|  | 				const exist = pagingComponent.items.map(x => x.id).indexOf(id); | ||||||
|  | 				pagingComponent.items[exist] = { | ||||||
|  | 					...pagingComponent.items[exist], | ||||||
|  | 					reads: [...pagingComponent.items[exist].reads, x.userId] | ||||||
|  | 				}; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onDeleted(id) { | ||||||
|  | 	const msg = pagingComponent.items.find(m => m.id === id); | ||||||
|  | 	if (msg) { | ||||||
|  | 		pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function thisScrollToBottom() { | ||||||
|  | 	scrollToBottom($$(rootEl).value, { behavior: "smooth" }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onIndicatorClick() { | ||||||
|  | 	showIndicator = false; | ||||||
|  | 	thisScrollToBottom(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | let scrollRemove: (() => void) | null = $ref(null); | ||||||
|  | 
 | ||||||
|  | function notifyNewMessage() { | ||||||
|  | 	showIndicator = true; | ||||||
|  | 
 | ||||||
|  | 	scrollRemove = onScrollBottom(rootEl, () => { | ||||||
|  | 		showIndicator = false; | ||||||
|  | 		scrollRemove = null; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function onVisibilitychange() { | ||||||
|  | 	if (document.hidden) return; | ||||||
|  | 	for (const message of pagingComponent.items) { | ||||||
|  | 		if (message.userId !== $i?.id && !message.isRead) { | ||||||
|  | 			connection?.send('read', { | ||||||
|  | 				id: message.id | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | onMounted(() => { | ||||||
|  | 	fetch(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | onBeforeUnmount(() => { | ||||||
|  | 	connection?.dispose(); | ||||||
|  | 	document.removeEventListener('visibilitychange', onVisibilitychange); | ||||||
|  | 	if (scrollRemove) scrollRemove(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | defineExpose({ | ||||||
|  | 	[symbols.PAGE_INFO]: computed(() => !fetching ? user ? { | ||||||
|  | 			userName: user, | ||||||
|  | 			avatar: user, | ||||||
|  | 		} : { | ||||||
|  | 			title: group?.name, | ||||||
|  | 			icon: 'fas fa-users', | ||||||
|  | 		} : null), | ||||||
|  | }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
| .mk-messaging-room { | .mk-messaging-room { | ||||||
|  | 	position: relative; | ||||||
|  | 
 | ||||||
| 	> .body { | 	> .body { | ||||||
| 		> .empty { | 		.more { | ||||||
| 			width: 100%; |  | ||||||
| 			margin: 0; |  | ||||||
| 			padding: 16px 8px 8px 8px; |  | ||||||
| 			text-align: center; |  | ||||||
| 			font-size: 0.8em; |  | ||||||
| 			opacity: 0.5; |  | ||||||
| 
 |  | ||||||
| 			i { |  | ||||||
| 				margin-right: 4px; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		> .no-history { |  | ||||||
| 			display: block; |  | ||||||
| 			margin: 0; |  | ||||||
| 			padding: 16px; |  | ||||||
| 			text-align: center; |  | ||||||
| 			font-size: 0.8em; |  | ||||||
| 			color: var(--messagingRoomInfo); |  | ||||||
| 			opacity: 0.5; |  | ||||||
| 
 |  | ||||||
| 			i { |  | ||||||
| 				margin-right: 4px; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		> .more { |  | ||||||
| 			display: block; | 			display: block; | ||||||
| 			margin: 16px auto; | 			margin: 16px auto; | ||||||
| 			padding: 0 12px; | 			padding: 0 12px; | ||||||
|  | @ -399,7 +325,9 @@ export default Component; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		> .messages { | 		.messages { | ||||||
|  | 			padding-top: 8px; | ||||||
|  | 
 | ||||||
| 			> ::v-deep(*) { | 			> ::v-deep(*) { | ||||||
| 				margin-bottom: 16px; | 				margin-bottom: 16px; | ||||||
| 			} | 			} | ||||||
|  | @ -408,29 +336,31 @@ export default Component; | ||||||
| 
 | 
 | ||||||
| 	> footer { | 	> footer { | ||||||
| 		width: 100%; | 		width: 100%; | ||||||
| 		position: relative; | 		position: sticky; | ||||||
|  | 		z-index: 2; | ||||||
|  | 		bottom: 0; | ||||||
|  | 		padding-top: 8px; | ||||||
|  | 
 | ||||||
|  | 		@media (max-width: 500px) { | ||||||
|  | 			bottom: 92px; | ||||||
|  | 		} | ||||||
| 
 | 
 | ||||||
| 		> .new-message { | 		> .new-message { | ||||||
| 			position: absolute; |  | ||||||
| 			top: -48px; |  | ||||||
| 			width: 100%; | 			width: 100%; | ||||||
| 			padding: 8px 0; | 			padding-bottom: 8px; | ||||||
| 			text-align: center; | 			text-align: center; | ||||||
| 
 | 
 | ||||||
| 			> button { | 			> button { | ||||||
| 				display: inline-block; | 				display: inline-block; | ||||||
| 				margin: 0; | 				margin: 0; | ||||||
| 				padding: 0 12px 0 30px; | 				padding: 0 12px; | ||||||
| 				line-height: 32px; | 				line-height: 32px; | ||||||
| 				font-size: 12px; | 				font-size: 12px; | ||||||
| 				border-radius: 16px; | 				border-radius: 16px; | ||||||
| 
 | 
 | ||||||
| 				> i { | 				> i { | ||||||
| 					position: absolute; | 					display: inline-block; | ||||||
| 					top: 0; | 					margin-right: 8px; | ||||||
| 					left: 10px; |  | ||||||
| 					line-height: 32px; |  | ||||||
| 					font-size: 16px; |  | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -455,6 +385,8 @@ export default Component; | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		> .form { | 		> .form { | ||||||
|  | 			max-height: 12em; | ||||||
|  | 			overflow-y: scroll; | ||||||
| 			border-top: solid 0.5px var(--divider); | 			border-top: solid 0.5px var(--divider); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -13,8 +13,8 @@ | ||||||
| 	<FormSection> | 	<FormSection> | ||||||
| 		<template #label>{{ $ts.signinHistory }}</template> | 		<template #label>{{ $ts.signinHistory }}</template> | ||||||
| 		<MkPagination :pagination="pagination"> | 		<MkPagination :pagination="pagination"> | ||||||
| 			<template v-slot="{items}"> | 			<template v-slot="{items, itemsContainer}"> | ||||||
| 				<div> | 				<div ref="itemsContainer"> | ||||||
| 					<div v-for="item in items" :key="item.id" v-panel class="timnmucd"> | 					<div v-for="item in items" :key="item.id" v-panel class="timnmucd"> | ||||||
| 						<header> | 						<header> | ||||||
| 							<i v-if="item.success" class="fas fa-check icon succ"></i> | 							<i v-if="item.success" class="fas fa-check icon succ"></i> | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| <template> | <template> | ||||||
| <div> | <div> | ||||||
| 	<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> | 	<MkPagination v-slot="{items, itemsContainer}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> | ||||||
| 		<div class="users _isolated"> | 		<div class="users _isolated" ref="itemsContainer"> | ||||||
| 			<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> | 			<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkPagination> | 	</MkPagination> | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| <template> | <template> | ||||||
| <div> | <div> | ||||||
| 	<MkPagination v-slot="{items}" :pagination="pagination"> | 	<MkPagination v-slot="{items, itemsContainer}" :pagination="pagination"> | ||||||
| 		<div class="jrnovfpt"> | 		<div class="jrnovfpt" ref="itemsContainer"> | ||||||
| 			<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | 			<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkPagination> | 	</MkPagination> | ||||||
|  |  | ||||||
|  | @ -1,57 +1,76 @@ | ||||||
| type ScrollBehavior = 'auto' | 'smooth' | 'instant'; | export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { | ||||||
| 
 | 	if (el == null || el.tagName === 'HTML') return null; | ||||||
| export function getScrollContainer(el: Element | null): Element | null { |  | ||||||
| 	if (el == null || el.tagName === 'BODY') return null; |  | ||||||
| 	const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); | 	const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); | ||||||
| 	if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
 | 	if ( | ||||||
|  | 		// xとyを個別に指定している場合、`hidden scroll`みたいな値になる
 | ||||||
|  | 		overflow.endsWith('scroll') || | ||||||
|  | 		overflow.endsWith('auto') | ||||||
|  | 	) { | ||||||
| 		return el; | 		return el; | ||||||
| 	} else { | 	} else { | ||||||
| 		return getScrollContainer(el.parentElement); | 		return getScrollContainer(el.parentElement); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function getScrollPosition(el: Element | null): number { | export function getStickyTop(el: HTMLElement, container: HTMLElement | null = null, top: number = 0) { | ||||||
|  | 	if (!el.parentElement) return top; | ||||||
|  | 	const data = el.dataset.stickyContainerHeaderHeight; | ||||||
|  | 	const newTop = data ? Number(data) + top : top; | ||||||
|  | 	if (el === container) return newTop; | ||||||
|  | 	return getStickyTop(el.parentElement, container, newTop); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function getScrollPosition(el: HTMLElement | null): number { | ||||||
| 	const container = getScrollContainer(el); | 	const container = getScrollContainer(el); | ||||||
| 	return container == null ? window.scrollY : container.scrollTop; | 	return container == null ? window.scrollY : container.scrollTop; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function isTopVisible(el: Element | null): boolean { | export function onScrollTop(el: HTMLElement, cb: Function, asobi: number = 1, once: boolean = false) { | ||||||
| 	const scrollTop = getScrollPosition(el); | 	// とりあえず評価してみる
 | ||||||
| 	const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
 | 	if (isTopVisible(el)) { | ||||||
|  | 		cb(); | ||||||
|  | 		if (once) return null; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	return scrollTop <= topPosition; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function onScrollTop(el: Element, cb) { |  | ||||||
| 	const container = getScrollContainer(el) || window; | 	const container = getScrollContainer(el) || window; | ||||||
|  | 
 | ||||||
| 	const onScroll = ev => { | 	const onScroll = ev => { | ||||||
| 		if (!document.body.contains(el)) return; | 		if (!document.body.contains(el)) return; | ||||||
| 		if (isTopVisible(el)) { | 		if (isTopVisible(el, asobi)) { | ||||||
| 			cb(); | 			cb(); | ||||||
| 			container.removeEventListener('scroll', onScroll); | 			if (once) removeListener(); | ||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
|  | 
 | ||||||
|  | 	function removeListener() { container.removeEventListener('scroll', onScroll) } | ||||||
| 	container.addEventListener('scroll', onScroll, { passive: true }); | 	container.addEventListener('scroll', onScroll, { passive: true }); | ||||||
|  | 	return removeListener; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function onScrollBottom(el: Element, cb) { | export function onScrollBottom(el: HTMLElement, cb: Function, asobi: number = 1, once: boolean = false) { | ||||||
| 	const container = getScrollContainer(el) || window; | 	const container = getScrollContainer(el); | ||||||
|  | 
 | ||||||
|  | 	// とりあえず評価してみる
 | ||||||
|  | 	if (isBottomVisible(el, asobi, container)) { | ||||||
|  | 		cb(); | ||||||
|  | 		if (once) return null; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	const containerOrWindow = container || window; | ||||||
| 	const onScroll = ev => { | 	const onScroll = ev => { | ||||||
| 		if (!document.body.contains(el)) return; | 		if (!document.body.contains(el)) return; | ||||||
| 		const pos = getScrollPosition(el); | 		if (isBottomVisible(el, 1, container)) { | ||||||
| 		if (pos + el.clientHeight > el.scrollHeight - 1) { |  | ||||||
| 			cb(); | 			cb(); | ||||||
| 			container.removeEventListener('scroll', onScroll); | 			if (once) removeListener(); | ||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
| 	container.addEventListener('scroll', onScroll, { passive: true }); | 
 | ||||||
|  | 	function removeListener() { containerOrWindow.removeEventListener('scroll', onScroll) } | ||||||
|  | 	containerOrWindow.addEventListener('scroll', onScroll, { passive: true }); | ||||||
|  | 	return removeListener; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function scroll(el: Element, options: { | export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) { | ||||||
| 	top?: number; |  | ||||||
| 	left?: number; |  | ||||||
| 	behavior?: ScrollBehavior; |  | ||||||
| }) { |  | ||||||
| 	const container = getScrollContainer(el); | 	const container = getScrollContainer(el); | ||||||
| 	if (container == null) { | 	if (container == null) { | ||||||
| 		window.scroll(options); | 		window.scroll(options); | ||||||
|  | @ -60,21 +79,55 @@ export function scroll(el: Element, options: { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) { | /** | ||||||
|  |  * Scroll to Top | ||||||
|  |  * @param el Scroll container element | ||||||
|  |  * @param options Scroll options | ||||||
|  |  */ | ||||||
|  | export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) { | ||||||
| 	scroll(el, { top: 0, ...options }); | 	scroll(el, { top: 0, ...options }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) { | /** | ||||||
| 	scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する
 |  * Scroll to Bottom | ||||||
|  |  * 通常のページで ウィジェット高さ > コンテンツ高さ の場合にうまく動くように | ||||||
|  |  * 若干処理が複雑になっている | ||||||
|  |  * @param el Content element | ||||||
|  |  * @param options Scroll options | ||||||
|  |  * @param container Scroll container element | ||||||
|  |  * @param sticky To add sticky-top | ||||||
|  |  * @param mobileButtons To add mobile buttons | ||||||
|  |  */ | ||||||
|  | export function scrollToBottom( | ||||||
|  | 	el: HTMLElement, | ||||||
|  | 	options: ScrollToOptions = {}, | ||||||
|  | 	container = getScrollContainer(el), | ||||||
|  | ) { | ||||||
|  | 	if (container) { | ||||||
|  | 		container.scroll({ top: el.scrollHeight - container.clientHeight + getStickyTop(el, container) || 0, ...options }); | ||||||
|  | 	} else { | ||||||
|  | 		window.scroll({ | ||||||
|  | 			top: (el.scrollHeight - window.innerHeight + getStickyTop(el, container) + (window.innerWidth <= 500 ? 96 : 0)) || 0, | ||||||
|  | 			...options | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function isBottom(el: Element, asobi = 0) { | export function isTopVisible(el: HTMLElement, asobi: number = 1): boolean { | ||||||
| 	const container = getScrollContainer(el); | 	const scrollTop = getScrollPosition(el); | ||||||
| 	const current = container | 	return scrollTop <= asobi; | ||||||
| 		? el.scrollTop + el.offsetHeight | } | ||||||
| 		: window.scrollY + window.innerHeight; | 
 | ||||||
| 	const max = container | export function isBottomVisible(el: HTMLElement, asobi = 1, container = getScrollContainer(el)) { | ||||||
| 		? el.scrollHeight | 	if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + asobi; | ||||||
| 		: document.body.offsetHeight; | 	return el.scrollHeight <= window.innerHeight + window.scrollY + asobi; | ||||||
| 	return current >= (max - asobi); | } | ||||||
|  | 
 | ||||||
|  | // https://ja.javascript.info/size-and-scroll-window#ref-932
 | ||||||
|  | export function getBodyScrollHeight() { | ||||||
|  | 	return Math.max( | ||||||
|  | 		document.body.scrollHeight, document.documentElement.scrollHeight, | ||||||
|  | 		document.body.offsetHeight, document.documentElement.offsetHeight, | ||||||
|  | 		document.body.clientHeight, document.documentElement.clientHeight | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										7
									
								
								packages/client/src/types/date-separated-list.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/client/src/types/date-separated-list.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | export type MisskeyEntity = { | ||||||
|  | 	id: string; | ||||||
|  | 	createdAt: string; | ||||||
|  | 	_shouldInsertAd_?: | ||||||
|  | 	boolean; | ||||||
|  | 	[x: string]: any; | ||||||
|  | }; | ||||||
|  | @ -44,7 +44,6 @@ import { defineComponent, defineAsyncComponent } from 'vue'; | ||||||
| import { host, instanceName } from '@/config'; | import { host, instanceName } from '@/config'; | ||||||
| import { search } from '@/scripts/search'; | import { search } from '@/scripts/search'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import MkPagination from '@/components/ui/pagination.vue'; |  | ||||||
| import MkButton from '@/components/ui/button.vue'; | import MkButton from '@/components/ui/button.vue'; | ||||||
| import XHeader from './header.vue'; | import XHeader from './header.vue'; | ||||||
| import { ColdDeviceStorage } from '@/store'; | import { ColdDeviceStorage } from '@/store'; | ||||||
|  | @ -55,7 +54,6 @@ const DESKTOP_THRESHOLD = 1100; | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
| 	components: { | 	components: { | ||||||
| 		XHeader, | 		XHeader, | ||||||
| 		MkPagination, |  | ||||||
| 		MkButton, | 		MkButton, | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue