wip: migrate paging components to composition api

#7681
This commit is contained in:
syuilo 2022-01-09 21:35:35 +09:00
parent a10be38d0e
commit 586c11251a
8 changed files with 374 additions and 677 deletions

View File

@ -1,44 +0,0 @@
<template>
<FormSlot>
<template #label><slot name="label"></slot></template>
<div class="abcaccfa">
<slot :items="items"></slot>
<div v-if="empty" key="_empty_" class="empty">
<slot name="empty"></slot>
</div>
<MkButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
</div>
</FormSlot>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import FormSlot from './slot.vue';
import paging from '@/scripts/paging';
export default defineComponent({
components: {
MkButton,
FormSlot,
},
mixins: [
paging({}),
],
props: {
pagination: {
required: true
},
},
});
</script>
<style lang="scss" scoped>
.abcaccfa {
}
</style>

View File

@ -1,114 +1,49 @@
<template>
<transition name="fade" mode="out-in">
<MkLoading v-if="fetching"/>
<MkError v-else-if="error" @retry="init()"/>
<div v-else-if="empty" class="_fullinfo">
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotes }}</div>
</div>
<div v-else class="giivymft" :class="{ noGap }">
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
<MkButton style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreFeature">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
</div>
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
</XList>
<div v-show="more && !reversed" style="margin-top: var(--margin);">
<MkButton v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import paging from '@/scripts/paging';
import XNote from './note.vue';
import XList from './date-separated-list.vue';
import MkButton from '@/components/ui/button.vue';
<template #default="{ items: notes }">
<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">
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
</XList>
</div>
</template>
</MkPagination>
</template>
export default defineComponent({
components: {
XNote, XList, MkButton,
},
<script lang="ts" setup>
import { ref } from 'vue';
import XNote from '@/components/note.vue';
import XList from '@/components/date-separated-list.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { Paging } from '@/components/ui/pagination.vue';
mixins: [
paging({
before: (self) => {
self.$emit('before');
},
const props = defineProps<{
pagination: Paging;
noGap?: boolean;
}>();
after: (self, e) => {
self.$emit('after', e);
}
}),
],
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
props: {
pagination: {
required: true
},
prop: {
type: String,
required: false
},
noGap: {
type: Boolean,
required: false,
default: false
},
},
const updated = (oldValue, newValue) => {
const i = pagingComponent.value.items.findIndex(n => n === oldValue);
pagingComponent.value.items[i] = newValue;
};
emits: ['before', 'after'],
computed: {
notes(): any[] {
return this.prop ? this.items.map(item => item[this.prop]) : this.items;
defineExpose({
prepend: (note) => {
pagingComponent.value?.prepend(note);
},
reversed(): boolean {
return this.pagination.reversed;
}
},
methods: {
updated(oldValue, newValue) {
const i = this.notes.findIndex(n => n === oldValue);
if (this.prop) {
this.items[i][this.prop] = newValue;
} else {
this.items[i] = newValue;
}
},
focus() {
this.$refs.notes.focus();
}
}
});
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.giivymft {
&.noGap {
> .notes {

View File

@ -1,117 +1,53 @@
<template>
<transition name="fade" mode="out-in">
<MkLoading v-if="fetching"/>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotifications }}</div>
</div>
</template>
<MkError v-else-if="error" @retry="init()"/>
<p v-else-if="empty" class="mfcuwfyp">{{ $ts.noNotifications }}</p>
<div v-else>
<XList v-slot="{ item: notification }" class="elsfgstc" :items="items" :no-gap="true">
<template #default="{ items: notifications }">
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification.note, $event)"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
</XList>
<MkButton v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" primary style="margin: var(--margin) auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
</div>
</transition>
</template>
</MkPagination>
</template>
<script lang="ts">
import { defineComponent, PropType, markRaw } from 'vue';
import paging from '@/scripts/paging';
import XNotification from './notification.vue';
import XList from './date-separated-list.vue';
import XNote from './note.vue';
<script lang="ts" setup>
import { defineComponent, PropType, markRaw, onUnmounted, onMounted, computed, ref } from 'vue';
import { notificationTypes } from 'misskey-js';
import MkPagination from '@/components/ui/pagination.vue';
import { Paging } from '@/components/ui/pagination.vue';
import XNotification from '@/components/notification.vue';
import XList from '@/components/date-separated-list.vue';
import XNote from '@/components/note.vue';
import * as os from '@/os';
import { stream } from '@/stream';
import MkButton from '@/components/ui/button.vue';
import { $i } from '@/account';
export default defineComponent({
components: {
XNotification,
XList,
XNote,
MkButton,
},
const props = defineProps<{
includeTypes?: PropType<typeof notificationTypes[number][]>;
unreadOnly?: boolean;
}>();
mixins: [
paging({}),
],
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
props: {
includeTypes: {
type: Array as PropType<typeof notificationTypes[number][]>,
required: false,
default: null,
},
unreadOnly: {
type: Boolean,
required: false,
default: false,
},
},
const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)));
data() {
return {
connection: null,
pagination: {
endpoint: 'i/notifications',
const pagination: Paging = {
endpoint: 'i/notifications' as const,
limit: 10,
params: () => ({
includeTypes: this.allIncludeTypes || undefined,
unreadOnly: this.unreadOnly,
})
},
params: computed(() => ({
includeTypes: allIncludeTypes.value || undefined,
unreadOnly: props.unreadOnly,
})),
};
},
computed: {
allIncludeTypes() {
return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
}
},
watch: {
includeTypes: {
handler() {
this.reload();
},
deep: true
},
unreadOnly: {
handler() {
this.reload();
},
},
// TODO: vue/vuex $i
// mutingNotificationTypes
'$i.mutingNotificationTypes': {
handler() {
if (this.includeTypes === null) {
this.reload();
}
},
deep: true
}
},
mounted() {
this.connection = markRaw(stream.useChannel('main'));
this.connection.on('notification', this.onNotification);
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
onNotification(notification) {
const isMuted = !this.allIncludeTypes.includes(notification.type);
const onNotification = (notification) => {
const isMuted = !allIncludeTypes.value.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', {
id: notification.id
@ -119,41 +55,31 @@ export default defineComponent({
}
if (!isMuted) {
this.prepend({
pagingComponent.value.prepend({
...notification,
isRead: document.visibilityState === 'visible'
});
}
},
noteUpdated(oldValue, newValue) {
const i = this.items.findIndex(n => n.note === oldValue);
this.items[i] = {
...this.items[i],
note: newValue
};
},
}
const noteUpdated = (oldValue, newValue) => {
const i = pagingComponent.value.items.findIndex(n => n.note === oldValue);
pagingComponent.value.items[i] = {
...pagingComponent.value.items[i],
note: newValue,
};
};
onMounted(() => {
const connection = stream.useChannel('main');
connection.on('notification', onNotification);
onUnmounted(() => {
connection.dispose();
});
});
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.mfcuwfyp {
margin: 0;
padding: 16px;
text-align: center;
color: var(--fg);
}
.elsfgstc {
background: var(--panel);
}

View File

@ -13,43 +13,247 @@
</slot>
</div>
<div v-else class="cxiknjgy">
<div v-else ref="rootEl">
<slot :items="items"></slot>
<div v-show="more" key="_more_" class="more _gap">
<MkButton v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
<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">
{{ $ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from './button.vue';
import paging from '@/scripts/paging';
<script lang="ts" setup>
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
import * as misskey from 'misskey-js';
import * as os from '@/os';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
import MkButton from '@/components/ui/button.vue';
export default defineComponent({
components: {
MkButton
},
const SECOND_FETCH_LIMIT = 30;
mixins: [
paging({}),
],
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
endpoint: E;
limit: number;
params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>;
props: {
pagination: {
required: true
},
/**
* 検索APIのようなページング不可なエンドポイントを利用する場合
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
*/
noPaging?: boolean;
disableAutoLoad: {
type: Boolean,
required: false,
default: false,
/**
* items 配列の中身を逆順にする(新しい方が最後)
*/
reversed?: boolean;
};
const props = withDefaults(defineProps<{
pagination: Paging;
disableAutoLoad?: boolean;
displayLimit?: number;
}>(), {
displayLimit: 30,
});
const rootEl = ref<HTMLElement>();
const items = ref([]);
const queue = ref([]);
const offset = ref(0);
const fetching = ref(true);
const moreFetching = ref(false);
const inited = ref(false);
const more = ref(false);
const backed = ref(false); //
const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0 && !fetching.value && inited.value);
const error = computed(() => !fetching.value && !inited.value);
const init = async () => {
queue.value = [];
fetching.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,
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
markRaw(item);
if (props.pagination.reversed) {
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))) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse() : res;
more.value = true;
} else {
items.value = props.pagination.reversed ? [...res].reverse() : res;
more.value = false;
}
offset.value = res.length;
inited.value = true;
fetching.value = false;
}, e => {
fetching.value = false;
});
};
const reload = () => {
items.value = [];
init();
};
const fetchMore = async () => {
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,
limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
markRaw(item);
if (props.pagination.reversed) {
if (i === res.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
}
}
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;
} else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = false;
}
offset.value += res.length;
moreFetching.value = false;
}, e => {
moreFetching.value = false;
});
};
const fetchMoreAhead = async () => {
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
moreFetching.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,
limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
}),
}).then(res => {
for (const item of res) {
markRaw(item);
}
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;
} else {
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
more.value = false;
}
offset.value += res.length;
moreFetching.value = false;
}, e => {
moreFetching.value = false;
});
};
const prepend = (item) => {
if (props.pagination.reversed) {
const container = getScrollContainer(rootEl.value);
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 {
const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
console.log(item, top);
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 append = (item) => {
items.value.push(item);
};
watch(props.pagination.params, init, { deep: true });
watch(queue, (a, b) => {
if (a.length === 0 && b.length === 0) return;
this.$emit('queue', queue.value.length);
}, { deep: true });
init();
onActivated(() => {
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop.value = window.scrollY === 0;
});
defineExpose({
items,
reload,
fetchMoreAhead,
prepend,
append,
});
</script>
@ -64,11 +268,9 @@ export default defineComponent({
}
.cxiknjgy {
> .more > .button {
> .button {
margin-left: auto;
margin-right: auto;
height: 48px;
min-width: 150px;
}
}
</style>

View File

@ -1,91 +1,39 @@
<template>
<MkError v-if="error" @retry="init()"/>
<div v-else class="efvhhmdq _isolated">
<div v-if="empty" class="no-users">
<p>{{ $ts.noUsers }}</p>
</div>
<div class="users">
<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
</div>
<button v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" class="more" :class="{ fetching: moreFetching }" :disabled="moreFetching" @click="fetchMore">
<template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }}
</button>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noUsers }}</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import paging from '@/scripts/paging';
import MkUserInfo from './user-info.vue';
<template #default="{ items: users }">
<div class="efvhhmdq">
<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
</div>
</template>
</MkPagination>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkUserInfo from '@/components/user-info.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { Paging } from '@/components/ui/pagination.vue';
import { userPage } from '@/filters/user';
export default defineComponent({
components: {
MkUserInfo,
},
const props = defineProps<{
pagination: Paging;
noGap?: boolean;
}>();
mixins: [
paging({}),
],
props: {
pagination: {
required: true
},
extract: {
required: false
},
expanded: {
type: Boolean,
default: true
},
},
computed: {
users() {
return this.extract ? this.extract(this.items) : this.items;
}
},
methods: {
userPage
}
});
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
</script>
<style lang="scss" scoped>
.efvhhmdq {
> .no-users {
text-align: center;
}
> .users {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: var(--margin);
}
> .more {
display: block;
width: 100%;
padding: 16px;
&:hover {
background: rgba(#000, 0.025);
}
&:active {
background: rgba(#000, 0.05);
}
&.fetching {
cursor: wait;
}
> i {
margin-right: 4px;
}
}
}
</style>

View File

@ -12,7 +12,7 @@
<FormSection>
<template #label>{{ $ts.signinHistory }}</template>
<FormPagination :pagination="pagination">
<MkPagination :pagination="pagination">
<template v-slot="{items}">
<div>
<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
@ -25,7 +25,7 @@
</div>
</div>
</template>
</FormPagination>
</MkPagination>
</FormSection>
<FormSection>
@ -42,7 +42,7 @@ import { defineComponent } from 'vue';
import FormSection from '@/components/form/section.vue';
import FormSlot from '@/components/form/slot.vue';
import FormButton from '@/components/ui/button.vue';
import FormPagination from '@/components/form/pagination.vue';
import MkPagination from '@/components/ui/pagination.vue';
import X2fa from './2fa.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
@ -51,7 +51,7 @@ export default defineComponent({
components: {
FormSection,
FormButton,
FormPagination,
MkPagination,
FormSlot,
X2fa,
},

View File

@ -1,60 +1,36 @@
<template>
<div v-sticky-container class="yrzkoczt">
<MkTab v-model="with_" class="tab">
<MkTab v-model="include" class="tab">
<option :value="null">{{ $ts.notes }}</option>
<option value="replies">{{ $ts.notesAndReplies }}</option>
<option value="files">{{ $ts.withFiles }}</option>
</MkTab>
<XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
<XNotes ref="timeline" :no-gap="true" :pagination="pagination"/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as misskey from 'misskey-js';
import XNotes from '@/components/notes.vue';
import MkTab from '@/components/tab.vue';
import * as os from '@/os';
export default defineComponent({
components: {
XNotes,
MkTab,
},
const props = defineProps<{
user: misskey.entities.UserDetailed;
}>();
props: {
user: {
type: Object,
required: true,
},
},
const include = ref<string | null>(null);
data() {
return {
date: null,
with_: null,
pagination: {
endpoint: 'users/notes',
const pagination = {
endpoint: 'users/notes' as const,
limit: 10,
params: init => ({
userId: this.user.id,
includeReplies: this.with_ === 'replies',
withFiles: this.with_ === 'files',
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
})
}
params: computed(() => ({
userId: props.user.id,
includeReplies: include.value === 'replies',
withFiles: include.value === 'files',
})),
};
},
watch: {
user() {
this.$refs.timeline.reload();
},
with_() {
this.$refs.timeline.reload();
},
},
});
</script>
<style lang="scss" scoped>

View File

@ -1,246 +0,0 @@
import { markRaw } from 'vue';
import * as os from '@/os';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
const SECOND_FETCH_LIMIT = 30;
// reversed: items 配列の中身を逆順にする(新しい方が最後)
export default (opts) => ({
emits: ['queue'],
data() {
return {
items: [],
queue: [],
offset: 0,
fetching: true,
moreFetching: false,
inited: false,
more: false,
backed: false, // 遡り中か否か
isBackTop: false,
};
},
computed: {
empty(): boolean {
return this.items.length === 0 && !this.fetching && this.inited;
},
error(): boolean {
return !this.fetching && !this.inited;
},
},
watch: {
pagination: {
handler() {
this.init();
},
deep: true
},
queue: {
handler(a, b) {
if (a.length === 0 && b.length === 0) return;
this.$emit('queue', this.queue.length);
},
deep: true
}
},
created() {
opts.displayLimit = opts.displayLimit || 30;
this.init();
},
activated() {
this.isBackTop = false;
},
deactivated() {
this.isBackTop = window.scrollY === 0;
},
methods: {
reload() {
this.items = [];
this.init();
},
replaceItem(finder, data) {
const i = this.items.findIndex(finder);
this.items[i] = data;
},
removeItem(finder) {
const i = this.items.findIndex(finder);
this.items.splice(i, 1);
},
async init() {
this.queue = [];
this.fetching = true;
if (opts.before) opts.before(this);
let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
if (params && params.then) params = await params;
if (params === null) return;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
}).then(items => {
for (let i = 0; i < items.length; i++) {
const item = items[i];
markRaw(item);
if (this.pagination.reversed) {
if (i === items.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
}
if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse() : items;
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse() : items;
this.more = false;
}
this.offset = items.length;
this.inited = true;
this.fetching = false;
if (opts.after) opts.after(this, null);
}, e => {
this.fetching = false;
if (opts.after) opts.after(this, e);
});
},
async fetchMore() {
if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
this.moreFetching = true;
this.backed = true;
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : {
untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (let i = 0; i < items.length; i++) {
const item = items[i];
markRaw(item);
if (this.pagination.reversed) {
if (i === items.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
}
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false;
}
this.offset += items.length;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
async fetchMoreFeature() {
if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
this.moreFetching = true;
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : {
sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (const item of items) {
markRaw(item);
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false;
}
this.offset += items.length;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
prepend(item) {
if (this.pagination.reversed) {
const container = getScrollContainer(this.$el);
const pos = getScrollPosition(this.$el);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
if (isBottom) {
// オーバーフローしたら古いアイテムは捨てる
if (this.items.length >= opts.displayLimit) {
// このやり方だとVue 3.2以降アニメーションが動かなくなる
//this.items = this.items.slice(-opts.displayLimit);
while (this.items.length >= opts.displayLimit) {
this.items.shift();
}
this.more = true;
}
}
this.items.push(item);
// TODO
} else {
const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
if (isTop) {
// Prepend the item
this.items.unshift(item);
// オーバーフローしたら古いアイテムは捨てる
if (this.items.length >= opts.displayLimit) {
// このやり方だとVue 3.2以降アニメーションが動かなくなる
//this.items = this.items.slice(0, opts.displayLimit);
while (this.items.length >= opts.displayLimit) {
this.items.pop();
}
this.more = true;
}
} else {
this.queue.push(item);
onScrollTop(this.$el, () => {
for (const item of this.queue) {
this.prepend(item);
}
this.queue = [];
});
}
}
},
append(item) {
this.items.push(item);
},
}
});