mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 16:38:20 +01:00
refactor: ♻️ Rewrite timeline rendering code
This commit is contained in:
parent
091615b04e
commit
d6f36eaecf
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="respondingTo" class="mb-4" role="region" aria-label="Responding to">
|
<div v-if="respondingTo" class="mb-4" role="region" aria-label="Responding to">
|
||||||
<OverlayScrollbarsComponent :defer="true" class="max-h-72 overflow-y-auto">
|
<OverlayScrollbarsComponent :defer="true" class="max-h-72 overflow-y-auto">
|
||||||
<Note :note="respondingTo" :small="true" :disabled="true" class="!rounded-none !bg-primary-500/10" />
|
<Note :element="respondingTo" :small="true" :disabled="true" class="!rounded-none !bg-primary-500/10" />
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 pb-4 pt-5">
|
<div class="px-6 pb-4 pt-5">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<aside v-bind="$props" role="complementary" :aria-expanded="open ? 'true' : 'false'"
|
<aside v-bind="$props" role="complementary" :aria-expanded="open ? 'true' : 'false'"
|
||||||
:class="['flex max-h-dvh overflow-hidden duration-200', open ? enterClass : leaveClass, direction === 'left' ? 'flex-row' : 'flex-row-reverse']">
|
:class="['flex max-h-dvh overflow-hidden duration-200', open ? enterClass : leaveClass, direction === 'left' ? 'flex-row' : 'flex-row-reverse']">
|
||||||
<OverlayScrollbarsComponent :defer="true"
|
<OverlayScrollbarsComponent :defer="true"
|
||||||
class="bg-dark-900 ring-1 ring-white/10 h-full overflow-y-auto w-full">
|
class="bg-dark-700 ring-1 ring-white/10 h-full overflow-y-auto w-full">
|
||||||
<slot />
|
<slot />
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
<button @click="open = !open" aria-label="Toggle sidebar"
|
<button @click="open = !open" aria-label="Toggle sidebar"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
class="[&:not(:first-child)]:mt-6 grid grid-cols-2 gap-4 [&>*]:aspect-square [&:has(>:last-child:nth-child(1))>*]:aspect-video [&:has(>:last-child:nth-child(1))]:block">
|
class="[&:not(:first-child)]:mt-6 grid grid-cols-2 gap-4 [&>*]:aspect-square [&:has(>:last-child:nth-child(1))>*]:aspect-video [&:has(>:last-child:nth-child(1))]:block">
|
||||||
<Attachment v-for="attachment of note.media_attachments" :key="attachment.id" :attachment="attachment" />
|
<Attachment v-for="attachment of note.media_attachments" :key="attachment.id" :attachment="attachment" />
|
||||||
</div>
|
</div>
|
||||||
<Note v-if="isQuote && note?.quote" :note="note?.quote" :small="true" class="mt-4 !rounded" />
|
<Note v-if="isQuote && note?.quote" :element="note?.quote" :small="true" class="mt-4 !rounded" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else
|
<div v-else
|
||||||
class="rounded text-center ring-1 !max-w-full ring-white/10 h-52 mt-6 prose prose-invert p-4 flex flex-col justify-center items-center">
|
class="rounded text-center ring-1 !max-w-full ring-white/10 h-52 mt-6 prose prose-invert p-4 flex flex-col justify-center items-center">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<Header :note="outputtedNote" :small="small" />
|
<Header :note="outputtedNote" :small="small" />
|
||||||
<NoteContent :note="outputtedNote" :loaded="loaded" :url="url" :content="content" :is-quote="isQuote"
|
<NoteContent :note="outputtedNote" :loaded="loaded" :url="url" :content="content" :is-quote="isQuote"
|
||||||
:should-hide="shouldHide" />
|
:should-hide="shouldHide" />
|
||||||
<Skeleton class="!h-10 w-full mt-6" :enabled="!props.note || !loaded" v-if="!small || !showInteractions">
|
<Skeleton class="!h-10 w-full mt-6" :enabled="!props.element || !loaded" v-if="!small || !showInteractions">
|
||||||
<div v-if="showInteractions"
|
<div v-if="showInteractions"
|
||||||
class="mt-6 flex flex-row items-stretch disabled:*:opacity-70 [&>button]:max-w-28 disabled:*:cursor-not-allowed relative justify-around text-sm h-10 hover:enabled:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center">
|
class="mt-6 flex flex-row items-stretch disabled:*:opacity-70 [&>button]:max-w-28 disabled:*:cursor-not-allowed relative justify-around text-sm h-10 hover:enabled:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center">
|
||||||
<button class="group" @click="outputtedNote && useEvent('note:reply', outputtedNote)"
|
<button class="group" @click="outputtedNote && useEvent('note:reply', outputtedNote)"
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item value="">
|
<Menu.Item value="">
|
||||||
<ButtonDropdown @click="copy(JSON.stringify(props.note, null, 4))" icon="tabler:code"
|
<ButtonDropdown @click="copy(JSON.stringify(props.element, null, 4))" icon="tabler:code"
|
||||||
class="w-full">
|
class="w-full">
|
||||||
Copy API
|
Copy API
|
||||||
Response
|
Response
|
||||||
|
|
@ -158,7 +158,7 @@ import ReplyHeader from "./reply-header.vue";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
note?: Status;
|
element?: Status;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showInteractions?: boolean;
|
showInteractions?: boolean;
|
||||||
|
|
@ -168,7 +168,7 @@ const props = withDefaults(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const noteRef = ref(props.note);
|
const noteRef = ref(props.element);
|
||||||
|
|
||||||
useListen("composer:send-edit", (note) => {
|
useListen("composer:send-edit", (note) => {
|
||||||
if (note.id === noteRef.value?.id) {
|
if (note.id === noteRef.value?.id) {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col p-1 bg-dark-400">
|
<div class="flex flex-col p-1 bg-dark-400">
|
||||||
<div class="px-4 pt-2 pb-3 flex flex-row gap-2 items-center">
|
<div class="px-4 pt-2 pb-3 flex flex-row gap-2 items-center">
|
||||||
<Skeleton :enabled="!notification" shape="rect" class="!h-6" :min-width="40" :max-width="100"
|
<Skeleton :enabled="!element" shape="rect" class="!h-6" :min-width="40" :max-width="100" width-unit="%">
|
||||||
width-unit="%">
|
|
||||||
<iconify-icon :icon="icon" width="1.5rem" height="1.5rem" class="text-gray-200" aria-hidden="true" />
|
<iconify-icon :icon="icon" width="1.5rem" height="1.5rem" class="text-gray-200" aria-hidden="true" />
|
||||||
<Avatar v-if="notification?.account?.avatar" :src="notification?.account.avatar"
|
<Avatar v-if="element?.account?.avatar" :src="element?.account.avatar"
|
||||||
:alt="`${notification?.account.acct}'s avatar'`"
|
:alt="`${element?.account.acct}'s avatar'`" class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
|
||||||
class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
|
|
||||||
<span class="text-gray-200 line-clamp-1"><strong v-html="display_name"></strong> {{ text
|
<span class="text-gray-200 line-clamp-1"><strong v-html="display_name"></strong> {{ text
|
||||||
}}</span>
|
}}</span>
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Note v-if="notification?.status || !notification" :note="notification?.status" :small="true" />
|
<Note v-if="element?.status || !element" :element="element?.status" :small="true" />
|
||||||
<div v-else-if="notification.account" class="p-6 ring-1 ring-white/5 bg-dark-800">
|
<div v-else-if="element.account" class="p-6 ring-1 ring-white/5 bg-dark-800">
|
||||||
<SmallCard :account="notification.account" />
|
<SmallCard :account="element.account" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="notification?.type === 'follow_request' && relationship?.requested_by"
|
<div v-if="element?.type === 'follow_request' && relationship?.requested_by"
|
||||||
class="w-full grid grid-cols-2 gap-4 p-2 ">
|
class="w-full grid grid-cols-2 gap-4 p-2 ">
|
||||||
<Button theme="primary" :loading="isWorkingOnFollowRequest"
|
<Button theme="primary" :loading="isWorkingOnFollowRequest"
|
||||||
@click="acceptFollowRequest"><span>Accept</span>
|
@click="acceptFollowRequest"><span>Accept</span>
|
||||||
|
|
@ -38,52 +36,49 @@ import Note from "../notes/note.vue";
|
||||||
import SmallCard from "../users/SmallCard.vue";
|
import SmallCard from "../users/SmallCard.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
notification?: Notification;
|
element?: Notification;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const isWorkingOnFollowRequest = ref(false);
|
const isWorkingOnFollowRequest = ref(false);
|
||||||
const { relationship } = useRelationship(
|
const { relationship } = useRelationship(
|
||||||
client,
|
client,
|
||||||
props.notification?.account?.id ?? null,
|
props.element?.account?.id ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const acceptFollowRequest = async () => {
|
const acceptFollowRequest = async () => {
|
||||||
if (!props.notification?.account) {
|
if (!props.element?.account) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isWorkingOnFollowRequest.value = true;
|
isWorkingOnFollowRequest.value = true;
|
||||||
const { data } = await client.value.acceptFollowRequest(
|
const { data } = await client.value.acceptFollowRequest(
|
||||||
props.notification.account.id,
|
props.element.account.id,
|
||||||
);
|
);
|
||||||
relationship.value = data;
|
relationship.value = data;
|
||||||
isWorkingOnFollowRequest.value = false;
|
isWorkingOnFollowRequest.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectFollowRequest = async () => {
|
const rejectFollowRequest = async () => {
|
||||||
if (!props.notification?.account) {
|
if (!props.element?.account) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isWorkingOnFollowRequest.value = true;
|
isWorkingOnFollowRequest.value = true;
|
||||||
const { data } = await client.value.rejectFollowRequest(
|
const { data } = await client.value.rejectFollowRequest(
|
||||||
props.notification.account.id,
|
props.element.account.id,
|
||||||
);
|
);
|
||||||
relationship.value = data;
|
relationship.value = data;
|
||||||
isWorkingOnFollowRequest.value = false;
|
isWorkingOnFollowRequest.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { display_name } = useParsedAccount(
|
const { display_name } = useParsedAccount(props.element?.account, settings);
|
||||||
props.notification?.account,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = computed(() => {
|
const text = computed(() => {
|
||||||
if (!props.notification) {
|
if (!props.element) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (props.notification.type) {
|
switch (props.element.type) {
|
||||||
case "mention":
|
case "mention":
|
||||||
return "mentioned you";
|
return "mentioned you";
|
||||||
case "reblog":
|
case "reblog":
|
||||||
|
|
@ -95,17 +90,17 @@ const text = computed(() => {
|
||||||
case "follow_request":
|
case "follow_request":
|
||||||
return "requested to follow you";
|
return "requested to follow you";
|
||||||
default: {
|
default: {
|
||||||
console.error("Unknown notification type", props.notification.type);
|
console.error("Unknown notification type", props.element.type);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
if (!props.notification) {
|
if (!props.element) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (props.notification.type) {
|
switch (props.element.type) {
|
||||||
case "mention":
|
case "mention":
|
||||||
return "tabler:at";
|
return "tabler:at";
|
||||||
case "reblog":
|
case "reblog":
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
<template>
|
<template>
|
||||||
<Timeline :timeline="timeline" :load-next="loadNext" :load-prev="loadPrev" />
|
<Timeline type="status" :items="(items as Status[])" :is-loading="isLoading" :has-reached-end="hasReachedEnd"
|
||||||
|
:error="error" :load-next="loadNext" :load-prev="loadPrev" :remove-item="removeItem"
|
||||||
|
:update-item="updateItem" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { Status } from "@lysand-org/client/types";
|
||||||
import Timeline from "./timeline.vue";
|
import Timeline from "./timeline.vue";
|
||||||
|
|
||||||
|
const client = useClient();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id?: string;
|
id: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const client = useClient();
|
const {
|
||||||
const timelineParameters = ref({});
|
error,
|
||||||
const { timeline, loadNext, loadPrev } = useAccountTimeline(
|
hasReachedEnd,
|
||||||
client.value,
|
isLoading,
|
||||||
props.id || null,
|
items,
|
||||||
timelineParameters,
|
loadNext,
|
||||||
);
|
loadPrev,
|
||||||
|
removeItem,
|
||||||
|
updateItem,
|
||||||
|
} = useAccountTimeline(client.value, props.id);
|
||||||
|
|
||||||
|
// Example of how to handle global events
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
timeline.value = timeline.value.filter((note) => note.id !== id);
|
removeItem(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
useListen("note:edit", (updatedNote) => {
|
||||||
|
updateItem(updatedNote);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<Timeline :timeline="timeline" :load-next="loadNext" :load-prev="loadPrev" />
|
<Timeline type="status" :items="(items as Status[])" :is-loading="isLoading" :has-reached-end="hasReachedEnd"
|
||||||
|
:error="error" :load-next="loadNext" :load-prev="loadPrev" :remove-item="removeItem"
|
||||||
|
:update-item="updateItem" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { useHomeTimeline } from "~/composables/HomeTimeline";
|
||||||
import Timeline from "./timeline.vue";
|
import Timeline from "./timeline.vue";
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const timelineParameters = ref({});
|
|
||||||
const { timeline, loadNext, loadPrev } = useHomeTimeline(
|
|
||||||
client.value,
|
|
||||||
timelineParameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
hasReachedEnd,
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
loadNext,
|
||||||
|
loadPrev,
|
||||||
|
removeItem,
|
||||||
|
updateItem,
|
||||||
|
} = useHomeTimeline(client.value);
|
||||||
|
|
||||||
|
// Example of how to handle global events
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
timeline.value = timeline.value.filter((note) => note.id !== id);
|
removeItem(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
useListen("note:edit", (updatedNote) => {
|
||||||
|
updateItem(updatedNote);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<Timeline :timeline="timeline" :load-next="loadNext" :load-prev="loadPrev" />
|
<Timeline type="status" :items="(items as Status[])" :is-loading="isLoading" :has-reached-end="hasReachedEnd"
|
||||||
|
:error="error" :load-next="loadNext" :load-prev="loadPrev" :remove-item="removeItem"
|
||||||
|
:update-item="updateItem" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { useLocalTimeline } from "~/composables/LocalTimeline";
|
||||||
import Timeline from "./timeline.vue";
|
import Timeline from "./timeline.vue";
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const timelineParameters = ref({});
|
|
||||||
const { timeline, loadNext, loadPrev } = useLocalTimeline(
|
|
||||||
client.value,
|
|
||||||
timelineParameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
hasReachedEnd,
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
loadNext,
|
||||||
|
loadPrev,
|
||||||
|
removeItem,
|
||||||
|
updateItem,
|
||||||
|
} = useLocalTimeline(client.value);
|
||||||
|
|
||||||
|
// Example of how to handle global events
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
timeline.value = timeline.value.filter((note) => note.id !== id);
|
removeItem(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
useListen("note:edit", (updatedNote) => {
|
||||||
|
updateItem(updatedNote);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,47 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<Notif v-for="notif of timeline" :key="notif.id" :notification="notif" />
|
<Timeline type="notification" :items="(items as Notification[])" :is-loading="isLoading"
|
||||||
<span ref="skeleton"></span>
|
:has-reached-end="hasReachedEnd" :error="error" :load-next="loadNext" :load-prev="loadPrev"
|
||||||
<Notif v-for="index of 5" v-if="!hasReachedEnd" :skeleton="true" />
|
:remove-item="removeItem" :update-item="updateItem" />
|
||||||
|
|
||||||
<div v-if="hasReachedEnd" class="text-center flex flex-row justify-center items-center py-10 text-gray-400 gap-3">
|
|
||||||
<iconify-icon name="tabler:message-off" width="1.5rem" height="1.5rem" />
|
|
||||||
<span>No more notifications, you've seen them all</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Notif from "../social-elements/notifications/notif.vue";
|
import type { Notification } from "@lysand-org/client/types";
|
||||||
|
import { useNotificationTimeline } from "~/composables/NotificationTimeline";
|
||||||
|
import Timeline from "./timeline.vue";
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const isLoading = ref(true);
|
const {
|
||||||
|
error,
|
||||||
const timelineParameters = ref({});
|
hasReachedEnd,
|
||||||
const hasReachedEnd = ref(false);
|
isLoading,
|
||||||
const { timeline, loadNext, loadPrev } = useNotificationTimeline(
|
items,
|
||||||
client.value,
|
loadNext,
|
||||||
timelineParameters,
|
loadPrev,
|
||||||
);
|
removeItem,
|
||||||
const skeleton = ref<HTMLSpanElement | null>(null);
|
updateItem,
|
||||||
|
} = useNotificationTimeline(client.value);
|
||||||
onMounted(() => {
|
|
||||||
useIntersectionObserver(skeleton, async (entries) => {
|
|
||||||
if (
|
|
||||||
entries[0]?.isIntersecting &&
|
|
||||||
!hasReachedEnd.value &&
|
|
||||||
!isLoading.value
|
|
||||||
) {
|
|
||||||
isLoading.value = true;
|
|
||||||
await loadNext();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(timeline, (newTimeline, oldTimeline) => {
|
|
||||||
isLoading.value = false;
|
|
||||||
// If less than NOTES_PER_PAGE statuses are returned, we have reached the end
|
|
||||||
if (newTimeline.length - oldTimeline.length < useConfig().NOTES_PER_PAGE) {
|
|
||||||
hasReachedEnd.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<Timeline :timeline="timeline" :load-next="loadNext" :load-prev="loadPrev" />
|
<Timeline type="status" :items="(items as Status[])" :is-loading="isLoading" :has-reached-end="hasReachedEnd"
|
||||||
|
:error="error" :load-next="loadNext" :load-prev="loadPrev" :remove-item="removeItem"
|
||||||
|
:update-item="updateItem" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { usePublicTimeline } from "~/composables/PublicTimeline";
|
||||||
import Timeline from "./timeline.vue";
|
import Timeline from "./timeline.vue";
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const timelineParameters = ref({});
|
|
||||||
const { timeline, loadNext, loadPrev } = usePublicTimeline(
|
|
||||||
client.value,
|
|
||||||
timelineParameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
hasReachedEnd,
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
loadNext,
|
||||||
|
loadPrev,
|
||||||
|
removeItem,
|
||||||
|
updateItem,
|
||||||
|
} = usePublicTimeline(client.value);
|
||||||
|
|
||||||
|
// Example of how to handle global events
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
timeline.value = timeline.value.filter((note) => note.id !== id);
|
removeItem(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
useListen("note:edit", (updatedNote) => {
|
||||||
|
updateItem(updatedNote);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
26
components/timelines/timeline-item.vue
Normal file
26
components/timelines/timeline-item.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<template>
|
||||||
|
<component :is="itemComponent" :element="item" @update="$emit('update', $event)"
|
||||||
|
@delete="$emit('delete', item?.id)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Notification, Status } from "@lysand-org/client/types";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import NoteItem from "../social-elements/notes/note.vue";
|
||||||
|
import NotificationItem from "../social-elements/notifications/notif.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item?: Status | Notification;
|
||||||
|
type: "status" | "notification";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const itemComponent = computed(() => {
|
||||||
|
if (props.type === "status") {
|
||||||
|
return NoteItem;
|
||||||
|
}
|
||||||
|
if (props.type === "notification") {
|
||||||
|
return NotificationItem;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -1,69 +1,92 @@
|
||||||
|
<!-- Timeline.vue -->
|
||||||
<template>
|
<template>
|
||||||
<TransitionGroup leave-active-class="ease-in duration-200" leave-from-class="scale-100 opacity-100"
|
<div class="timeline">
|
||||||
leave-to-class="opacity-0 scale-90">
|
<TransitionGroup name="timeline-item" tag="div" class="timeline-items">
|
||||||
<Note v-for="note of timeline" :key="note.id" :note="note" />
|
<TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem"
|
||||||
</TransitionGroup>
|
@delete="removeItem" />
|
||||||
<span ref="skeleton"></span>
|
</TransitionGroup>
|
||||||
<Note v-for="_ of 5" v-if="!hasReachedEnd" :skeleton="true" />
|
|
||||||
|
|
||||||
<div v-if="hasReachedEnd" class="text-center flex flex-row justify-center items-center py-10 text-gray-400 gap-3">
|
<TimelineItem v-if="isLoading" :type="type" v-for="_ in 5" />
|
||||||
<iconify-icon icon="tabler:message-off" width="1.5rem" height="1.5rem" />
|
|
||||||
<span>No more posts, you've seen them all</span>
|
<div v-if="error" class="timeline-error">
|
||||||
|
{{ error.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasReachedEnd && items.length > 0"
|
||||||
|
class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10">
|
||||||
|
<span class="text-lg font-semibold">You've scrolled so far, there's nothing left to show.</span>
|
||||||
|
<span class="text-sm">You can always go back and see what you missed.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="hasReachedEnd && items.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10">
|
||||||
|
<span class="text-lg font-semibold">There's nothing to show here.</span>
|
||||||
|
<span class="text-sm">Either you're all caught up or there's nothing to show.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!infiniteScroll.value" class="py-10 px-4">
|
||||||
|
<Button theme="secondary" @click="loadNext" :disabled="isLoading" class="w-full">
|
||||||
|
Load More
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else ref="loadMoreTrigger" class="h-20"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Status } from "@lysand-org/client/types";
|
import type { Notification, Status } from "@lysand-org/client/types";
|
||||||
import Note from "../social-elements/notes/note.vue";
|
import { useIntersectionObserver } from "@vueuse/core";
|
||||||
|
import { onMounted, watch } from "vue";
|
||||||
|
import Button from "~/packages/ui/components/buttons/button.vue";
|
||||||
|
import { SettingIds } from "~/settings";
|
||||||
|
import TimelineItem from "./timeline-item.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
timeline: Status[];
|
items: Status[] | Notification[];
|
||||||
loadNext: () => Promise<void>;
|
type: "status" | "notification";
|
||||||
loadPrev: () => Promise<void>;
|
isLoading: boolean;
|
||||||
|
hasReachedEnd: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
loadNext: () => void;
|
||||||
|
loadPrev: () => void;
|
||||||
|
removeItem: (id: string) => void;
|
||||||
|
updateItem: ((item: Status) => void) | ((item: Notification) => void);
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isLoading = ref(true);
|
const emit = defineEmits<(e: "update") => void>();
|
||||||
|
|
||||||
const hasReachedEnd = ref(false);
|
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
||||||
const skeleton = ref<HTMLSpanElement | null>(null);
|
|
||||||
|
|
||||||
onMounted(() => {
|
useIntersectionObserver(loadMoreTrigger, ([observer]) => {
|
||||||
useIntersectionObserver(skeleton, async (entries) => {
|
if (observer?.isIntersecting && !props.isLoading && !props.hasReachedEnd) {
|
||||||
if (
|
props.loadNext();
|
||||||
entries[0]?.isIntersecting &&
|
}
|
||||||
!hasReachedEnd.value &&
|
|
||||||
!isLoading.value
|
|
||||||
) {
|
|
||||||
isLoading.value = true;
|
|
||||||
await props.loadNext();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useListen("composer:send", () => {
|
const infiniteScroll = useSetting(SettingIds.InfiniteScroll);
|
||||||
props.loadPrev();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every 5 seconds, load newer posts (prev)
|
|
||||||
useIntervalFn(() => {
|
|
||||||
props.loadPrev();
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.timeline,
|
() => props.items,
|
||||||
(newTimeline, oldTimeline) => {
|
() => {
|
||||||
// If posts are deleted, don't start loading more posts
|
emit("update");
|
||||||
if (newTimeline.length === oldTimeline.length - 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isLoading.value = false;
|
|
||||||
// If less than NOTES_PER_PAGE statuses are returned, we have reached the end
|
|
||||||
if (
|
|
||||||
newTimeline.length - oldTimeline.length <
|
|
||||||
useConfig().NOTES_PER_PAGE
|
|
||||||
) {
|
|
||||||
hasReachedEnd.value = true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
|
||||||
|
onMounted(() => {
|
||||||
|
props.loadNext();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-item-enter-active,
|
||||||
|
.timeline-item-leave-active {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-enter-from,
|
||||||
|
.timeline-item-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,32 +1,15 @@
|
||||||
import type { LysandClient } from "@lysand-org/client";
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
import type { Status } from "@lysand-org/client/types";
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export const useAccountTimeline = (
|
export function useAccountTimeline(
|
||||||
client: LysandClient | null,
|
client: LysandClient,
|
||||||
id: MaybeRef<string | null>,
|
accountId: string,
|
||||||
options: MaybeRef<{
|
options: Partial<TimelineOptions<Status>> = {},
|
||||||
limit?: number | undefined;
|
) {
|
||||||
max_id?: string | undefined;
|
return useTimeline(client, {
|
||||||
since_id?: string | undefined;
|
fetchFunction: (client, opts) =>
|
||||||
min_id?: string | undefined;
|
client.getAccountStatuses(accountId, opts),
|
||||||
pinned?: boolean | undefined;
|
...options,
|
||||||
exclude_replies?: boolean | undefined;
|
});
|
||||||
exclude_reblogs?: boolean | undefined;
|
}
|
||||||
only_media?: boolean;
|
|
||||||
}>,
|
|
||||||
): {
|
|
||||||
timeline: Ref<Status[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
return useIdTimeline(
|
|
||||||
client,
|
|
||||||
id,
|
|
||||||
(client, options) =>
|
|
||||||
client?.getAccountStatuses(ref(id).value ?? "", {
|
|
||||||
only_media: false,
|
|
||||||
...options,
|
|
||||||
}),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
import type { LysandClient } from "@lysand-org/client";
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
import type { Status } from "@lysand-org/client/types";
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export const useHomeTimeline = (
|
export function useHomeTimeline(
|
||||||
client: LysandClient | null,
|
client: LysandClient,
|
||||||
options: MaybeRef<{
|
options: Partial<TimelineOptions<Status>> = {},
|
||||||
local?: boolean;
|
) {
|
||||||
limit?: number;
|
return useTimeline(client, {
|
||||||
max_id?: string;
|
fetchFunction: (client, opts) => client.getHomeTimeline(opts),
|
||||||
since_id?: string;
|
...options,
|
||||||
min_id?: string;
|
});
|
||||||
}>,
|
}
|
||||||
): {
|
|
||||||
timeline: Ref<Status[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
return useTimeline(
|
|
||||||
client,
|
|
||||||
(client, options) => client?.getHomeTimeline(options),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
import type { LysandClient } from "@lysand-org/client";
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
import type { Status } from "@lysand-org/client/types";
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export const useLocalTimeline = (
|
export function useLocalTimeline(
|
||||||
client: LysandClient | null,
|
client: LysandClient,
|
||||||
options: MaybeRef<
|
options: Partial<TimelineOptions<Status>> = {},
|
||||||
Partial<{
|
) {
|
||||||
only_media: boolean;
|
return useTimeline(client, {
|
||||||
max_id: string;
|
fetchFunction: (client, opts) => client.getLocalTimeline(opts),
|
||||||
since_id: string;
|
...options,
|
||||||
min_id: string;
|
});
|
||||||
limit: number;
|
}
|
||||||
}>
|
|
||||||
>,
|
|
||||||
): {
|
|
||||||
timeline: Ref<Status[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
return useTimeline(
|
|
||||||
client,
|
|
||||||
(client, options) => client?.getLocalTimeline(options),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
import type { LysandClient } from "@lysand-org/client";
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
import type { Notification } from "@lysand-org/client/types";
|
import type { Notification } from "@lysand-org/client/types";
|
||||||
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export const useNotificationTimeline = (
|
export function useNotificationTimeline(
|
||||||
client: LysandClient | null,
|
client: LysandClient,
|
||||||
options: MaybeRef<{
|
options: Partial<TimelineOptions<Notification>> = {},
|
||||||
limit?: number | undefined;
|
) {
|
||||||
max_id?: string | undefined;
|
return useTimeline(client, {
|
||||||
since_id?: string | undefined;
|
fetchFunction: (client, opts) => client.getNotifications(opts),
|
||||||
min_id?: string | undefined;
|
...options,
|
||||||
exclude_types?: string[] | undefined;
|
});
|
||||||
account_id?: string | undefined;
|
}
|
||||||
}>,
|
|
||||||
): {
|
|
||||||
timeline: Ref<Notification[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
return useTimeline(
|
|
||||||
client,
|
|
||||||
// @ts-expect-error dont listen to the voices jesse
|
|
||||||
(client, options) => client?.getNotifications(options),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
import type { LysandClient } from "@lysand-org/client";
|
import type { LysandClient } from "@lysand-org/client";
|
||||||
import type { Status } from "@lysand-org/client/types";
|
import type { Status } from "@lysand-org/client/types";
|
||||||
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export const usePublicTimeline = (
|
export function usePublicTimeline(
|
||||||
client: LysandClient | null,
|
client: LysandClient,
|
||||||
options: MaybeRef<{
|
options: Partial<TimelineOptions<Status>> = {},
|
||||||
only_media?: boolean;
|
) {
|
||||||
limit?: number;
|
return useTimeline(client, {
|
||||||
max_id?: string;
|
fetchFunction: (client, opts) => client.getPublicTimeline(opts),
|
||||||
since_id?: string;
|
...options,
|
||||||
min_id?: string;
|
});
|
||||||
}>,
|
}
|
||||||
): {
|
|
||||||
timeline: Ref<Status[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
return useTimeline(
|
|
||||||
client,
|
|
||||||
(client, options) => client?.getPublicTimeline(options),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,184 +1,116 @@
|
||||||
import type { LysandClient, Output } from "@lysand-org/client";
|
import type { LysandClient, Output } from "@lysand-org/client";
|
||||||
|
import type { Notification, Status } from "@lysand-org/client/types";
|
||||||
|
import { useIntervalFn } from "@vueuse/core";
|
||||||
|
|
||||||
interface BaseOptions {
|
export interface TimelineOptions<T> {
|
||||||
max_id?: string;
|
fetchFunction: (
|
||||||
min_id?: string;
|
client: LysandClient,
|
||||||
|
options: object,
|
||||||
|
) => Promise<Output<T[]>>;
|
||||||
|
updateInterval?: number;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchTimelineFunction<Element, Options> = (
|
export function useTimeline<T extends Status | Notification>(
|
||||||
client: LysandClient,
|
client: LysandClient,
|
||||||
options: Options & BaseOptions,
|
options: TimelineOptions<T>,
|
||||||
) => Promise<Output<Element[]>>;
|
) {
|
||||||
|
const items = ref<T[]>([]) as Ref<T[]>;
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const hasReachedEnd = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
|
||||||
export const useTimeline = <
|
const nextMaxId = ref<string | undefined>(undefined);
|
||||||
Element extends {
|
const prevMinId = ref<string | undefined>(undefined);
|
||||||
id: string;
|
|
||||||
},
|
|
||||||
Options,
|
|
||||||
>(
|
|
||||||
client: LysandClient | null,
|
|
||||||
fetchTimeline: FetchTimelineFunction<Element, Options> | null | undefined,
|
|
||||||
options: MaybeRef<Options & BaseOptions>,
|
|
||||||
): {
|
|
||||||
timeline: Ref<Element[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
if (!(client && fetchTimeline)) {
|
|
||||||
return {
|
|
||||||
timeline: ref([]),
|
|
||||||
loadNext: async () => {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
loadPrev: async () => {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchedNotes: Ref<Element[]> = ref([]);
|
const fetchItems = async (direction: "next" | "prev") => {
|
||||||
const fetchedNoteIds = new Set<string>();
|
if (isLoading.value || (direction === "next" && hasReachedEnd.value)) {
|
||||||
let nextMaxId: string | undefined = undefined;
|
return;
|
||||||
let prevMinId: string | undefined = undefined;
|
}
|
||||||
|
|
||||||
const loadNext = async () => {
|
isLoading.value = true;
|
||||||
const response = await fetchTimeline(client, {
|
error.value = null;
|
||||||
...(ref(options).value as Options & BaseOptions),
|
|
||||||
max_id: nextMaxId,
|
|
||||||
limit: useConfig().NOTES_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newNotes = response.data.filter(
|
try {
|
||||||
(note) => !fetchedNoteIds.has(note.id),
|
const response = await options.fetchFunction(client, {
|
||||||
);
|
limit: options.limit || 20,
|
||||||
if (newNotes.length > 0) {
|
max_id: direction === "next" ? nextMaxId.value : undefined,
|
||||||
fetchedNotes.value = [...fetchedNotes.value, ...newNotes];
|
min_id: direction === "prev" ? prevMinId.value : undefined,
|
||||||
nextMaxId = newNotes[newNotes.length - 1]?.id;
|
});
|
||||||
for (const note of newNotes) {
|
|
||||||
fetchedNoteIds.add(note.id);
|
const newItems = response.data.filter(
|
||||||
|
(item: T) =>
|
||||||
|
!items.value.some((existing) => existing.id === item.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (direction === "next") {
|
||||||
|
items.value.push(...newItems);
|
||||||
|
if (newItems.length < (options.limit || 20)) {
|
||||||
|
hasReachedEnd.value = true;
|
||||||
|
}
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
nextMaxId.value = newItems[newItems.length - 1]?.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.value.unshift(...newItems);
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
prevMinId.value = newItems[0]?.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) {
|
||||||
nextMaxId = undefined;
|
error.value =
|
||||||
|
e instanceof Error ? e : new Error("An error occurred");
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPrev = async () => {
|
const loadNext = () => fetchItems("next");
|
||||||
const response = await fetchTimeline(client, {
|
const loadPrev = () => fetchItems("prev");
|
||||||
...(ref(options).value as Options & BaseOptions),
|
|
||||||
min_id: prevMinId,
|
|
||||||
limit: useConfig().NOTES_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newNotes = response.data.filter(
|
const addItem = (newItem: T) => {
|
||||||
(note) => !fetchedNoteIds.has(note.id),
|
items.value.unshift(newItem);
|
||||||
);
|
};
|
||||||
if (newNotes.length > 0) {
|
|
||||||
fetchedNotes.value = [...newNotes, ...fetchedNotes.value];
|
const removeItem = (id: string) => {
|
||||||
prevMinId = newNotes[0]?.id;
|
const index = items.value.findIndex((item) => item.id === id);
|
||||||
for (const note of newNotes) {
|
if (index !== -1) {
|
||||||
fetchedNoteIds.add(note.id);
|
items.value.splice(index, 1);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
prevMinId = undefined;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
const updateItem = (updatedItem: T) => {
|
||||||
() => ref(options).value,
|
const index = items.value.findIndex(
|
||||||
async ({ max_id, min_id }) => {
|
(item) => item.id === updatedItem.id,
|
||||||
nextMaxId = max_id;
|
|
||||||
prevMinId = min_id;
|
|
||||||
await loadNext();
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
return { timeline: fetchedNotes, loadNext, loadPrev };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useIdTimeline = <
|
|
||||||
Element extends {
|
|
||||||
id: string;
|
|
||||||
},
|
|
||||||
Options,
|
|
||||||
>(
|
|
||||||
client: LysandClient | null,
|
|
||||||
id: MaybeRef<string | null>,
|
|
||||||
fetchTimeline: FetchTimelineFunction<Element, Options> | null | undefined,
|
|
||||||
options: MaybeRef<Options & BaseOptions>,
|
|
||||||
): {
|
|
||||||
timeline: Ref<Element[]>;
|
|
||||||
loadNext: () => Promise<void>;
|
|
||||||
loadPrev: () => Promise<void>;
|
|
||||||
} => {
|
|
||||||
if (!(client && fetchTimeline)) {
|
|
||||||
return {
|
|
||||||
timeline: ref([]),
|
|
||||||
loadNext: async () => {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
loadPrev: async () => {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchedNotes: Ref<Element[]> = ref([]);
|
|
||||||
const fetchedNoteIds = new Set<string>();
|
|
||||||
let nextMaxId: string | undefined = undefined;
|
|
||||||
let prevMinId: string | undefined = undefined;
|
|
||||||
|
|
||||||
const loadNext = async () => {
|
|
||||||
const response = await fetchTimeline(client, {
|
|
||||||
...(ref(options).value as Options & BaseOptions),
|
|
||||||
max_id: nextMaxId,
|
|
||||||
limit: useConfig().NOTES_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newNotes = response.data.filter(
|
|
||||||
(note) => !fetchedNoteIds.has(note.id),
|
|
||||||
);
|
);
|
||||||
if (newNotes.length > 0) {
|
if (index !== -1) {
|
||||||
fetchedNotes.value = [...fetchedNotes.value, ...newNotes];
|
items.value[index] = updatedItem;
|
||||||
nextMaxId = newNotes[newNotes.length - 1]?.id;
|
|
||||||
for (const note of newNotes) {
|
|
||||||
fetchedNoteIds.add(note.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nextMaxId = undefined;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPrev = async () => {
|
// Set up periodic updates
|
||||||
const response = await fetchTimeline(client, {
|
const { pause, resume } = useIntervalFn(() => {
|
||||||
...(ref(options).value as Options & BaseOptions),
|
loadPrev();
|
||||||
min_id: prevMinId,
|
}, options.updateInterval || 30000);
|
||||||
limit: useConfig().NOTES_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newNotes = response.data.filter(
|
onMounted(() => {
|
||||||
(note) => !fetchedNoteIds.has(note.id),
|
loadNext();
|
||||||
);
|
resume();
|
||||||
if (newNotes.length > 0) {
|
});
|
||||||
fetchedNotes.value = [...newNotes, ...fetchedNotes.value];
|
|
||||||
prevMinId = newNotes[0]?.id;
|
onUnmounted(() => {
|
||||||
for (const note of newNotes) {
|
pause();
|
||||||
fetchedNoteIds.add(note.id);
|
});
|
||||||
}
|
|
||||||
} else {
|
return {
|
||||||
prevMinId = undefined;
|
items,
|
||||||
}
|
isLoading,
|
||||||
|
hasReachedEnd,
|
||||||
|
error,
|
||||||
|
loadNext,
|
||||||
|
loadPrev,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
updateItem,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
watch(
|
|
||||||
[() => ref(id).value, () => ref(options).value],
|
|
||||||
async ([id, { max_id, min_id }]) => {
|
|
||||||
nextMaxId = max_id;
|
|
||||||
prevMinId = min_id;
|
|
||||||
id && (await loadNext());
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
return { timeline: fetchedNotes, loadNext, loadPrev };
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ark-ui/vue": "^3.4.0",
|
"@ark-ui/vue": "^3.4.0",
|
||||||
"@lysand-org/client": "^0.2.1",
|
"@lysand-org/client": "^0.2.2",
|
||||||
"@nuxt/fonts": "^0.7.0",
|
"@nuxt/fonts": "^0.7.0",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@vee-validate/nuxt": "^4.13.1",
|
"@vee-validate/nuxt": "^4.13.1",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loaded" :defer="true" class="mx-auto max-w-2xl w-full pb-72">
|
<div v-if="loaded" :defer="true" class="mx-auto max-w-2xl w-full pb-72">
|
||||||
<Note v-for="note of context?.ancestors" :note="note" />
|
<Note v-for="note of context?.ancestors" :element="note" />
|
||||||
<div ref="element" class="first:rounded-t last:rounded-b overflow-hidden">
|
<div ref="element" class="first:rounded-t last:rounded-b overflow-hidden">
|
||||||
<Note class="!rounded-none border-2 border-primary-500" v-if="note" :note="note" />
|
<Note class="!rounded-none border-2 border-primary-500" v-if="note" :element="note" />
|
||||||
</div>
|
</div>
|
||||||
<Note v-for="note of context?.descendants" :note="note" />
|
<Note v-for="note of context?.descendants" :element="note" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mx-auto max-w-2xl w-full overflow-y-auto">
|
<div v-else class="mx-auto max-w-2xl w-full overflow-y-auto">
|
||||||
<Note v-for="_ of 5" :skeleton="true" />
|
<Note v-for="_ of 5" :skeleton="true" />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="mx-auto max-w-2xl w-full">
|
<div class="mx-auto max-w-2xl w-full">
|
||||||
<TimelineScroller>
|
<TimelineScroller>
|
||||||
<AccountProfile :account="account ?? undefined" />
|
<AccountProfile :account="account ?? undefined" />
|
||||||
<AccountTimeline :id="accountId" :key="accountId" />
|
<AccountTimeline v-if="accountId" :id="accountId" :key="accountId" />
|
||||||
</TimelineScroller>
|
</TimelineScroller>
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
</div>
|
</div>
|
||||||
<TimelineScroller v-else>
|
<TimelineScroller v-else>
|
||||||
<Greeting />
|
<Greeting />
|
||||||
<div class="rounded overflow-hidden ring-1 ring-white/10">
|
<div class="rounded overflow-hidden">
|
||||||
<Notifications />
|
<Notifications />
|
||||||
</div>
|
</div>
|
||||||
</TimelineScroller>
|
</TimelineScroller>
|
||||||
|
|
|
||||||
10
settings.ts
10
settings.ts
|
|
@ -65,6 +65,7 @@ export enum SettingIds {
|
||||||
CustomEmojis = "custom-emojis",
|
CustomEmojis = "custom-emojis",
|
||||||
ShowContentWarning = "show-content-warning",
|
ShowContentWarning = "show-content-warning",
|
||||||
PopupAvatarHover = "popup-avatar-hover",
|
PopupAvatarHover = "popup-avatar-hover",
|
||||||
|
InfiniteScroll = "infinite-scroll",
|
||||||
ConfirmDelete = "confirm-delete",
|
ConfirmDelete = "confirm-delete",
|
||||||
ConfirmFollow = "confirm-follow",
|
ConfirmFollow = "confirm-follow",
|
||||||
ConfirmReblog = "confirm-reblog",
|
ConfirmReblog = "confirm-reblog",
|
||||||
|
|
@ -124,6 +125,15 @@ export const settings = [
|
||||||
path: SettingPages.Behaviour,
|
path: SettingPages.Behaviour,
|
||||||
notImplemented: true,
|
notImplemented: true,
|
||||||
} as Setting<SettingType.Boolean>,
|
} as Setting<SettingType.Boolean>,
|
||||||
|
{
|
||||||
|
id: SettingIds.InfiniteScroll,
|
||||||
|
title: "Infinite Scroll",
|
||||||
|
description:
|
||||||
|
"Automatically load more notes when reaching the bottom of the page",
|
||||||
|
type: SettingType.Boolean,
|
||||||
|
value: true,
|
||||||
|
path: SettingPages.Behaviour,
|
||||||
|
} as Setting<SettingType.Boolean>,
|
||||||
{
|
{
|
||||||
id: SettingIds.ConfirmDelete,
|
id: SettingIds.ConfirmDelete,
|
||||||
title: "Confirm Delete",
|
title: "Confirm Delete",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue