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">
|
||||
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 { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
|
||||
type: Array as PropType<MisskeyEntity[]>,
|
||||
required: true,
|
||||
},
|
||||
direction: {
|
||||
|
@ -30,9 +31,15 @@ export default defineComponent({
|
|||
required: 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) {
|
||||
const date = new Date(time).getDate();
|
||||
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(
|
||||
defaultStore.state.animation ? TransitionGroup : 'div',
|
||||
defaultStore.state.animation ? {
|
||||
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
|
||||
name: 'list',
|
||||
tag: 'div',
|
||||
{
|
||||
class: {
|
||||
'sqadhkmv': true,
|
||||
'noGap': props.noGap
|
||||
},
|
||||
'data-direction': props.direction,
|
||||
'data-reversed': props.reversed ? 'true' : 'false',
|
||||
} : {
|
||||
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
|
||||
},
|
||||
...(defaultStore.state.animation ? {
|
||||
name: 'list',
|
||||
tag: 'div',
|
||||
onBeforeLeave,
|
||||
onLeaveCanceled,
|
||||
} : {}),
|
||||
},
|
||||
{ default: renderChildren });
|
||||
}
|
||||
});
|
||||
|
@ -108,6 +134,8 @@ export default defineComponent({
|
|||
|
||||
<style lang="scss">
|
||||
.sqadhkmv {
|
||||
display: flex;
|
||||
|
||||
> *:empty {
|
||||
display: none;
|
||||
}
|
||||
|
@ -120,24 +148,46 @@ export default defineComponent({
|
|||
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 {
|
||||
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"] {
|
||||
> .list-enter-from {
|
||||
> .list-enter-from,
|
||||
> .list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(64px);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-direction="down"] {
|
||||
> .list-enter-from {
|
||||
> .list-enter-from,
|
||||
> .list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-64px);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-reversed="true"] {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
&[data-reversed="false"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
> .separator {
|
||||
text-align: center;
|
||||
|
||||
|
|
|
@ -1,71 +1,63 @@
|
|||
<template>
|
||||
<div ref="rootEl">
|
||||
<slot name="header"></slot>
|
||||
<div ref="bodyEl">
|
||||
<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
autoSticky: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
const props = withDefaults(defineProps<{
|
||||
autoSticky?: boolean;
|
||||
}>(), {
|
||||
autoSticky: false,
|
||||
})
|
||||
|
||||
setup(props, context) {
|
||||
const rootEl = ref<HTMLElement>(null);
|
||||
const bodyEl = ref<HTMLElement>(null);
|
||||
const rootEl = $ref<HTMLElement>();
|
||||
const bodyEl = $ref<HTMLElement>();
|
||||
|
||||
const calc = () => {
|
||||
const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
|
||||
let headerHeight: string | undefined = $ref();
|
||||
|
||||
const header = rootEl.value.children[0];
|
||||
if (header === bodyEl.value) {
|
||||
bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
|
||||
} else {
|
||||
bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
||||
const calc = () => {
|
||||
const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
|
||||
|
||||
if (props.autoSticky) {
|
||||
header.style.setProperty('--stickyTop', currentStickyTop);
|
||||
header.style.position = 'sticky';
|
||||
header.style.top = 'var(--stickyTop)';
|
||||
header.style.zIndex = '1';
|
||||
}
|
||||
}
|
||||
};
|
||||
const header = rootEl.children[0] as HTMLElement;
|
||||
if (header === bodyEl) {
|
||||
bodyEl.style.setProperty('--stickyTop', currentStickyTop);
|
||||
} else {
|
||||
bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
||||
headerHeight = header.offsetHeight.toString();
|
||||
|
||||
onMounted(() => {
|
||||
calc();
|
||||
if (props.autoSticky) {
|
||||
header.style.setProperty('--stickyTop', currentStickyTop);
|
||||
header.style.position = 'sticky';
|
||||
header.style.top = 'var(--stickyTop)';
|
||||
header.style.zIndex = '1';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
window.setTimeout(() => {
|
||||
calc();
|
||||
}, 100);
|
||||
});
|
||||
const observer = new MutationObserver(() => {
|
||||
window.setTimeout(() => {
|
||||
calc();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
observer.observe(rootEl.value, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
});
|
||||
onMounted(() => {
|
||||
calc();
|
||||
|
||||
onUnmounted(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
observer.observe(rootEl, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
rootEl,
|
||||
bodyEl,
|
||||
};
|
||||
},
|
||||
onUnmounted(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -7,9 +7,19 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notes }">
|
||||
<template #default="{ items: notes, itemsContainerWrapped }">
|
||||
<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"/>
|
||||
</XList>
|
||||
</div>
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notifications }">
|
||||
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
|
||||
<template #default="{ items: notifications, itemsContainerWrapped }">
|
||||
<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"/>
|
||||
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
|
||||
</XList>
|
||||
|
@ -29,7 +29,7 @@ import { stream } from '@/stream';
|
|||
import { $i } from '@/account';
|
||||
|
||||
const props = defineProps<{
|
||||
includeTypes?: PropType<typeof notificationTypes[number][]>;
|
||||
includeTypes?: typeof notificationTypes[number][];
|
||||
unreadOnly?: boolean;
|
||||
}>();
|
||||
|
||||
|
|
|
@ -14,9 +14,15 @@
|
|||
</div>
|
||||
|
||||
<div v-else ref="rootEl">
|
||||
<slot :items="items"></slot>
|
||||
<div v-show="more" key="_more_" class="cxiknjgy _gap">
|
||||
<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
|
||||
<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 }}
|
||||
</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 }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
|
@ -25,14 +31,17 @@
|
|||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
|
||||
<script lang="ts">
|
||||
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
import { onScrollTop, isTopVisible, 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 { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
|
||||
const SECOND_FETCH_LIMIT = 30;
|
||||
const ASOBI = 16;
|
||||
|
||||
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
|
||||
endpoint: E;
|
||||
|
@ -51,8 +60,11 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
|
|||
reversed?: boolean;
|
||||
|
||||
offsetMode?: boolean;
|
||||
};
|
||||
|
||||
pageEl?: HTMLElement;
|
||||
};
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
disableAutoLoad?: boolean;
|
||||
|
@ -65,21 +77,90 @@ const emit = defineEmits<{
|
|||
(e: 'queue', count: number): void;
|
||||
}>();
|
||||
|
||||
type Item = { id: string; [another: string]: unknown; };
|
||||
let rootEl = $ref<HTMLElement>();
|
||||
|
||||
const rootEl = ref<HTMLElement>();
|
||||
const items = ref<Item[]>([]);
|
||||
const queue = ref<Item[]>([]);
|
||||
/*
|
||||
* itemsContainer: itemsの実体DOMsの親コンテナ(=v-forの直上)のHTMLElement
|
||||
*
|
||||
* 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 fetching = ref(true);
|
||||
const moreFetching = ref(false);
|
||||
const more = ref(false);
|
||||
const backed = ref(false); // 遡り中か否か
|
||||
const isBackTop = ref(false);
|
||||
const empty = computed(() => items.value.length === 0);
|
||||
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 = [];
|
||||
fetching.value = true;
|
||||
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 => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (props.pagination.reversed) {
|
||||
if (i === res.length - 2) item._shouldInsertAd_ = true;
|
||||
} else {
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
|
||||
res.pop();
|
||||
items.value = props.pagination.reversed ? [...res].reverse() : res;
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
items.value = res;
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = props.pagination.reversed ? [...res].reverse() : res;
|
||||
items.value = res;
|
||||
more.value = false;
|
||||
}
|
||||
offset.value = res.length;
|
||||
|
@ -112,15 +190,14 @@ const init = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
const reload = (): void => {
|
||||
const reload = (): Promise<void> => {
|
||||
items.value = [];
|
||||
init();
|
||||
return init();
|
||||
};
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
|
||||
moreFetching.value = true;
|
||||
backed.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
|
@ -128,27 +205,57 @@ const fetchMore = async (): Promise<void> => {
|
|||
...(props.pagination.offsetMode ? {
|
||||
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 => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (props.pagination.reversed) {
|
||||
if (i === res.length - 9) item._shouldInsertAd_ = true;
|
||||
} else {
|
||||
if (i === 10) item._shouldInsertAd_ = true;
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
|
||||
more.value = false;
|
||||
if (props.pagination.reversed) {
|
||||
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;
|
||||
moreFetching.value = false;
|
||||
}, e => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
|
@ -164,15 +271,15 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
...(props.pagination.offsetMode ? {
|
||||
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 => {
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
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;
|
||||
} else {
|
||||
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
|
||||
items.value = items.value.concat(res) ;
|
||||
more.value = false;
|
||||
}
|
||||
offset.value += res.length;
|
||||
|
@ -182,99 +289,91 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
const prepend = (item: Item): void => {
|
||||
if (props.pagination.reversed) {
|
||||
if (rootEl.value) {
|
||||
const container = getScrollContainer(rootEl.value);
|
||||
if (container == null) return; // TODO?
|
||||
|
||||
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 prepend = (item: MisskeyEntity): void => {
|
||||
// 初回表示時はunshiftだけでOK
|
||||
if (!rootEl) {
|
||||
items.value.unshift(item);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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);
|
||||
items.value[i] = replacer(items.value[i]);
|
||||
};
|
||||
|
||||
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 });
|
||||
|
||||
init();
|
||||
const inited = init();
|
||||
|
||||
onActivated(() => {
|
||||
isBackTop.value = false;
|
||||
});
|
||||
|
||||
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({
|
||||
items,
|
||||
backed,
|
||||
more,
|
||||
inited,
|
||||
reload,
|
||||
fetchMoreAhead,
|
||||
prepend,
|
||||
append,
|
||||
append: appendItem,
|
||||
updateItem,
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -62,10 +62,6 @@ function dragClear(fn) {
|
|||
}
|
||||
|
||||
export default defineComponent({
|
||||
provide: {
|
||||
inWindow: true
|
||||
},
|
||||
|
||||
props: {
|
||||
padding: {
|
||||
type: Boolean,
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: users }">
|
||||
<div class="efvhhmdq">
|
||||
<template #default="{ items: users, itemsContainer }">
|
||||
<div class="efvhhmdq" ref="itemsContainer">
|
||||
<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -5,8 +5,10 @@ export default {
|
|||
//const query = binding.value;
|
||||
|
||||
const header = src.children[0];
|
||||
const body = src.children[1];
|
||||
const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
|
||||
src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
|
||||
if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString();
|
||||
header.style.setProperty('--stickyTop', currentStickyTop);
|
||||
header.style.position = 'sticky';
|
||||
header.style.top = 'var(--stickyTop)';
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
</div>
|
||||
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
|
||||
<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
|
||||
<template v-slot="{items}">
|
||||
<div class="ldhfsamy">
|
||||
<template v-slot="{items, itemsContainer}">
|
||||
<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)">
|
||||
<img :src="emoji.url" class="img" :alt="emoji.name"/>
|
||||
<div class="body">
|
||||
|
@ -45,8 +45,8 @@
|
|||
</FormSplit>
|
||||
<MkPagination :pagination="remotePagination">
|
||||
<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
|
||||
<template v-slot="{items}">
|
||||
<div class="ldhfsamy">
|
||||
<template v-slot="{items, itemsContainer}">
|
||||
<div class="ldhfsamy" ref="itemsContainer">
|
||||
<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"/>
|
||||
<div class="body">
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
|
||||
<template #default="{ items, itemsContainerWrapped }">
|
||||
<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"/>
|
||||
</XList>
|
||||
</template>
|
||||
|
|
|
@ -41,8 +41,8 @@
|
|||
</FormSplit>
|
||||
</div>
|
||||
|
||||
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
|
||||
<div class="dqokceoi">
|
||||
<MkPagination v-slot="{items, itemsContainer}" ref="instances" :key="host + state" :pagination="pagination">
|
||||
<div class="dqokceoi" ref="itemsContainer">
|
||||
<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="table">
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
<div>{{ $ts.noFollowRequests }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot="{items}">
|
||||
<div class="mk-follow-requests">
|
||||
<template v-slot="{items, itemsContainer}">
|
||||
<div class="mk-follow-requests" ref="itemsContainer">
|
||||
<div v-for="req in items" :key="req.id" class="user _panel">
|
||||
<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
|
||||
<div class="body">
|
||||
|
|
|
@ -9,32 +9,32 @@
|
|||
<div v-if="tab === 'explore'">
|
||||
<MkFolder class="_gap">
|
||||
<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
|
||||
<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
|
||||
<div class="vfpdbgtk">
|
||||
<MkPagination v-slot="{items, itemsContainer}" :pagination="recentPostsPagination" :disable-auto-load="true">
|
||||
<div class="vfpdbgtk" ref="itemsContainer">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkFolder>
|
||||
<MkFolder class="_gap">
|
||||
<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
|
||||
<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
|
||||
<div class="vfpdbgtk">
|
||||
<MkPagination v-slot="{items, itemsContainer}" :pagination="popularPostsPagination" :disable-auto-load="true">
|
||||
<div class="vfpdbgtk" ref="itemsContainer">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkFolder>
|
||||
</div>
|
||||
<div v-else-if="tab === 'liked'">
|
||||
<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
|
||||
<div class="vfpdbgtk">
|
||||
<MkPagination v-slot="{items, itemsContainer}" :pagination="likedPostsPagination">
|
||||
<div class="vfpdbgtk" ref="itemsContainer">
|
||||
<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'my'">
|
||||
<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
|
||||
<MkPagination v-slot="{items}" :pagination="myPostsPagination">
|
||||
<div class="vfpdbgtk">
|
||||
<MkPagination v-slot="{items, itemsContainer}" :pagination="myPostsPagination">
|
||||
<div class="vfpdbgtk" ref="itemsContainer">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
|
|
|
@ -36,8 +36,8 @@
|
|||
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
|
||||
<MkContainer :max-height="300" :foldable="true" class="other">
|
||||
<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
|
||||
<MkPagination v-slot="{items}" :pagination="otherPostsPagination">
|
||||
<div class="sdrarzaf">
|
||||
<MkPagination v-slot="{items, itemsContainer}" :pagination="otherPostsPagination">
|
||||
<div class="sdrarzaf" ref="itemsContainer">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
|
|
|
@ -4,219 +4,216 @@
|
|||
@drop.stop="onDrop"
|
||||
>
|
||||
<textarea
|
||||
ref="text"
|
||||
ref="textEl"
|
||||
v-model="text"
|
||||
:placeholder="$ts.inputMessageHere"
|
||||
:placeholder="i18n.ts.inputMessageHere"
|
||||
@keydown="onKeydown"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
|
||||
<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
|
||||
<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>
|
||||
<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>
|
||||
<input ref="file" type="file" @change="onChangeFile"/>
|
||||
<footer>
|
||||
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
|
||||
<div class="buttons">
|
||||
<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="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import autosize from 'autosize';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { formatTimeString } from '@/scripts/format-time-string';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { Autocomplete } from '@/scripts/autocomplete';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
requird: false,
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
requird: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: null,
|
||||
file: null,
|
||||
sending: false,
|
||||
typing: throttle(3000, () => {
|
||||
stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
|
||||
}),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
draftKey(): string {
|
||||
return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
|
||||
},
|
||||
canSend(): boolean {
|
||||
return (this.text != null && this.text != '') || this.file != null;
|
||||
},
|
||||
room(): any {
|
||||
return this.$parent;
|
||||
const props = defineProps<{
|
||||
user?: Misskey.entities.UserDetailed | null;
|
||||
group?: Misskey.entities.UserGroup | null;
|
||||
}>();
|
||||
|
||||
let textEl = $ref<HTMLTextAreaElement>();
|
||||
let fileEl = $ref<HTMLInputElement>();
|
||||
|
||||
let text: string = $ref('');
|
||||
let file: Misskey.entities.DriveFile | null = $ref(null);
|
||||
let sending = $ref(false);
|
||||
const typing = throttle(3000, () => {
|
||||
stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
|
||||
});
|
||||
|
||||
let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
|
||||
let canSend = $computed(() => (text != null && text != '') || file != null);
|
||||
|
||||
watch([$$(text), $$(file)], saveDraft);
|
||||
|
||||
async function onPaste(e: ClipboardEvent) {
|
||||
if (!e.clipboardData) return;
|
||||
|
||||
const data = e.clipboardData;
|
||||
const items = data.items;
|
||||
|
||||
if (items.length == 1) {
|
||||
if (items[0].kind == 'file') {
|
||||
const file = items[0].getAsFile();
|
||||
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);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.saveDraft();
|
||||
},
|
||||
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;
|
||||
} else {
|
||||
if (items[0].kind == 'file') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.onlyOneFileCanBeAttached
|
||||
});
|
||||
},
|
||||
|
||||
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>
|
||||
|
||||
|
@ -230,7 +227,7 @@ export default defineComponent({
|
|||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
margin: 0;
|
||||
padding: 16px 16px 0 16px;
|
||||
resize: none;
|
||||
|
@ -245,26 +242,16 @@ export default defineComponent({
|
|||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .file {
|
||||
padding: 8px;
|
||||
color: #444;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> .send {
|
||||
position: absolute;
|
||||
footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
transition: color 0.1s ease;
|
||||
color: var(--accent);
|
||||
background: var(--panel);
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
> .file {
|
||||
padding: 8px;
|
||||
color: var(--fg);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -316,21 +303,39 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
._button {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
transition: color 0.1s ease;
|
||||
.buttons {
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
._button {
|
||||
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 {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
> .send {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--accentDarken);
|
||||
transition: color 0s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,45 +35,28 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import MkUrlPreview from '@/components/url-preview.vue';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkUrlPreview
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
required: true
|
||||
},
|
||||
isGroup: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
const props = defineProps<{
|
||||
message: Misskey.entities.MessagingMessage;
|
||||
isGroup?: boolean;
|
||||
}>();
|
||||
|
||||
const isMe = $computed(() => props.message.userId === $i?.id);
|
||||
const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
|
||||
|
||||
function del() {
|
||||
os.api('messaging/messages/delete', {
|
||||
messageId: props.message.id
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -266,6 +249,7 @@ export default defineComponent({
|
|||
&.isMe {
|
||||
flex-direction: row-reverse;
|
||||
padding-right: var(--margin);
|
||||
right: var(--margin); // 削除時にposition: absoluteになったときに使う
|
||||
|
||||
> .content {
|
||||
padding-right: 16px;
|
||||
|
|
|
@ -2,378 +2,304 @@
|
|||
<div class="_section"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@drop.prevent.stop="onDrop"
|
||||
ref="rootEl"
|
||||
>
|
||||
<div class="_content mk-messaging-room">
|
||||
<div class="body">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
|
||||
<p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
|
||||
<button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages">
|
||||
<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
|
||||
</button>
|
||||
<XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed>
|
||||
<XMessage :key="message.id" :message="message" :is-group="group != null"/>
|
||||
</XList>
|
||||
<MkPagination ref="pagingComponent" :key="userAcct || groupId" v-if="pagination" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noMessagesYet }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<footer>
|
||||
<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>
|
||||
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
<MkEllipsis/>
|
||||
</div>
|
||||
<transition :name="$store.state.animation ? 'fade' : ''">
|
||||
<div v-show="showIndicator" class="new-message">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
|
||||
<transition :name="animation ? 'fade' : ''">
|
||||
<div class="new-message" v-show="showIndicator">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, markRaw } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
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 MkPagination, { Paging } from '@/components/ui/pagination.vue';
|
||||
import XMessage from './messaging-room.message.vue';
|
||||
import XForm from './messaging-room.form.vue';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
|
||||
import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { popout } from '@/scripts/popout';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import * as symbols from '@/symbols';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const Component = defineComponent({
|
||||
components: {
|
||||
XMessage,
|
||||
XForm,
|
||||
XList,
|
||||
},
|
||||
const props = defineProps<{
|
||||
userAcct?: string;
|
||||
groupId?: string;
|
||||
}>();
|
||||
|
||||
inject: ['inWindow'],
|
||||
let rootEl = $ref<HTMLDivElement>();
|
||||
let formEl = $ref<InstanceType<typeof XForm>>();
|
||||
let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
|
||||
|
||||
props: {
|
||||
userAcct: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
groupId: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
let fetching = $ref(true);
|
||||
let user: Misskey.entities.UserDetailed | null = $ref(null);
|
||||
let group: Misskey.entities.UserGroup | null = $ref(null);
|
||||
let typers: Misskey.entities.User[] = $ref([]);
|
||||
let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
|
||||
let showIndicator = $ref(false);
|
||||
const {
|
||||
animation
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
data() {
|
||||
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()
|
||||
),
|
||||
};
|
||||
},
|
||||
let pagination: Paging | null = $ref(null);
|
||||
|
||||
computed: {
|
||||
form(): any {
|
||||
return this.$refs.form;
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
watch([() => props.userAcct, () => props.groupId], () => {
|
||||
if (connection) connection.dispose();
|
||||
fetch();
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-messaging-room {
|
||||
position: relative;
|
||||
|
||||
> .body {
|
||||
> .empty {
|
||||
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 {
|
||||
.more {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
padding: 0 12px;
|
||||
|
@ -399,7 +325,9 @@ export default Component;
|
|||
}
|
||||
}
|
||||
|
||||
> .messages {
|
||||
.messages {
|
||||
padding-top: 8px;
|
||||
|
||||
> ::v-deep(*) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
@ -408,29 +336,31 @@ export default Component;
|
|||
|
||||
> footer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
padding-top: 8px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
bottom: 92px;
|
||||
}
|
||||
|
||||
> .new-message {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
padding-bottom: 8px;
|
||||
text-align: center;
|
||||
|
||||
> button {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 12px 0 30px;
|
||||
padding: 0 12px;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
|
||||
> i {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10px;
|
||||
line-height: 32px;
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -455,6 +385,8 @@ export default Component;
|
|||
}
|
||||
|
||||
> .form {
|
||||
max-height: 12em;
|
||||
overflow-y: scroll;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
<FormSection>
|
||||
<template #label>{{ $ts.signinHistory }}</template>
|
||||
<MkPagination :pagination="pagination">
|
||||
<template v-slot="{items}">
|
||||
<div>
|
||||
<template v-slot="{items, itemsContainer}">
|
||||
<div ref="itemsContainer">
|
||||
<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
|
||||
<header>
|
||||
<i v-if="item.success" class="fas fa-check icon succ"></i>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
|
||||
<div class="users _isolated">
|
||||
<MkPagination v-slot="{items, itemsContainer}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
|
||||
<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"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<MkPagination v-slot="{items}" :pagination="pagination">
|
||||
<div class="jrnovfpt">
|
||||
<MkPagination v-slot="{items, itemsContainer}" :pagination="pagination">
|
||||
<div class="jrnovfpt" ref="itemsContainer">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
|
|
|
@ -1,57 +1,76 @@
|
|||
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
|
||||
|
||||
export function getScrollContainer(el: Element | null): Element | null {
|
||||
if (el == null || el.tagName === 'BODY') return null;
|
||||
export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
||||
if (el == null || el.tagName === 'HTML') return null;
|
||||
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;
|
||||
} else {
|
||||
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);
|
||||
return container == null ? window.scrollY : container.scrollTop;
|
||||
}
|
||||
|
||||
export function isTopVisible(el: Element | null): boolean {
|
||||
const scrollTop = getScrollPosition(el);
|
||||
const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
|
||||
export function onScrollTop(el: HTMLElement, cb: Function, asobi: number = 1, once: boolean = false) {
|
||||
// とりあえず評価してみる
|
||||
if (isTopVisible(el)) {
|
||||
cb();
|
||||
if (once) return null;
|
||||
}
|
||||
|
||||
return scrollTop <= topPosition;
|
||||
}
|
||||
|
||||
export function onScrollTop(el: Element, cb) {
|
||||
const container = getScrollContainer(el) || window;
|
||||
|
||||
const onScroll = ev => {
|
||||
if (!document.body.contains(el)) return;
|
||||
if (isTopVisible(el)) {
|
||||
if (isTopVisible(el, asobi)) {
|
||||
cb();
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
if (once) removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
function removeListener() { container.removeEventListener('scroll', onScroll) }
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return removeListener;
|
||||
}
|
||||
|
||||
export function onScrollBottom(el: Element, cb) {
|
||||
const container = getScrollContainer(el) || window;
|
||||
export function onScrollBottom(el: HTMLElement, cb: Function, asobi: number = 1, once: boolean = false) {
|
||||
const container = getScrollContainer(el);
|
||||
|
||||
// とりあえず評価してみる
|
||||
if (isBottomVisible(el, asobi, container)) {
|
||||
cb();
|
||||
if (once) return null;
|
||||
}
|
||||
|
||||
const containerOrWindow = container || window;
|
||||
const onScroll = ev => {
|
||||
if (!document.body.contains(el)) return;
|
||||
const pos = getScrollPosition(el);
|
||||
if (pos + el.clientHeight > el.scrollHeight - 1) {
|
||||
if (isBottomVisible(el, 1, container)) {
|
||||
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: {
|
||||
top?: number;
|
||||
left?: number;
|
||||
behavior?: ScrollBehavior;
|
||||
}) {
|
||||
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
|
||||
const container = getScrollContainer(el);
|
||||
if (container == null) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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) {
|
||||
const container = getScrollContainer(el);
|
||||
const current = container
|
||||
? el.scrollTop + el.offsetHeight
|
||||
: window.scrollY + window.innerHeight;
|
||||
const max = container
|
||||
? el.scrollHeight
|
||||
: document.body.offsetHeight;
|
||||
return current >= (max - asobi);
|
||||
export function isTopVisible(el: HTMLElement, asobi: number = 1): boolean {
|
||||
const scrollTop = getScrollPosition(el);
|
||||
return scrollTop <= asobi;
|
||||
}
|
||||
|
||||
export function isBottomVisible(el: HTMLElement, asobi = 1, container = getScrollContainer(el)) {
|
||||
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + asobi;
|
||||
return el.scrollHeight <= window.innerHeight + window.scrollY + 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 { search } from '@/scripts/search';
|
||||
import * as os from '@/os';
|
||||
import MkPagination from '@/components/ui/pagination.vue';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import XHeader from './header.vue';
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
|
@ -55,7 +54,6 @@ const DESKTOP_THRESHOLD = 1100;
|
|||
export default defineComponent({
|
||||
components: {
|
||||
XHeader,
|
||||
MkPagination,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
|
|
Loading…
Reference in a new issue