refactor: ♻️ Rewrite timeline rendering code

This commit is contained in:
Jesse Wierzbinski 2024-06-28 17:05:50 -10:00
parent 091615b04e
commit d6f36eaecf
No known key found for this signature in database
24 changed files with 392 additions and 435 deletions

View file

@ -1,7 +1,7 @@
<template>
<div v-if="respondingTo" class="mb-4" role="region" aria-label="Responding to">
<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>
</div>
<div class="px-6 pb-4 pt-5">

View file

@ -2,7 +2,7 @@
<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']">
<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 />
</OverlayScrollbarsComponent>
<button @click="open = !open" aria-label="Toggle sidebar"

View file

@ -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">
<Attachment v-for="attachment of note.media_attachments" :key="attachment.id" :attachment="attachment" />
</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 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">

View file

@ -16,7 +16,7 @@
<Header :note="outputtedNote" :small="small" />
<NoteContent :note="outputtedNote" :loaded="loaded" :url="url" :content="content" :is-quote="isQuote"
: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"
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)"
@ -61,7 +61,7 @@
</ButtonDropdown>
</Menu.Item>
<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">
Copy API
Response
@ -158,7 +158,7 @@ import ReplyHeader from "./reply-header.vue";
const props = withDefaults(
defineProps<{
note?: Status;
element?: Status;
small?: boolean;
disabled?: 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) => {
if (note.id === noteRef.value?.id) {

View file

@ -1,22 +1,20 @@
<template>
<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">
<Skeleton :enabled="!notification" shape="rect" class="!h-6" :min-width="40" :max-width="100"
width-unit="%">
<Skeleton :enabled="!element" shape="rect" class="!h-6" :min-width="40" :max-width="100" width-unit="%">
<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"
:alt="`${notification?.account.acct}'s avatar'`"
class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
<Avatar v-if="element?.account?.avatar" :src="element?.account.avatar"
:alt="`${element?.account.acct}'s avatar'`" 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>
</Skeleton>
</div>
<div>
<Note v-if="notification?.status || !notification" :note="notification?.status" :small="true" />
<div v-else-if="notification.account" class="p-6 ring-1 ring-white/5 bg-dark-800">
<SmallCard :account="notification.account" />
<Note v-if="element?.status || !element" :element="element?.status" :small="true" />
<div v-else-if="element.account" class="p-6 ring-1 ring-white/5 bg-dark-800">
<SmallCard :account="element.account" />
</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 ">
<Button theme="primary" :loading="isWorkingOnFollowRequest"
@click="acceptFollowRequest"><span>Accept</span>
@ -38,52 +36,49 @@ import Note from "../notes/note.vue";
import SmallCard from "../users/SmallCard.vue";
const props = defineProps<{
notification?: Notification;
element?: Notification;
}>();
const client = useClient();
const isWorkingOnFollowRequest = ref(false);
const { relationship } = useRelationship(
client,
props.notification?.account?.id ?? null,
props.element?.account?.id ?? null,
);
const acceptFollowRequest = async () => {
if (!props.notification?.account) {
if (!props.element?.account) {
return;
}
isWorkingOnFollowRequest.value = true;
const { data } = await client.value.acceptFollowRequest(
props.notification.account.id,
props.element.account.id,
);
relationship.value = data;
isWorkingOnFollowRequest.value = false;
};
const rejectFollowRequest = async () => {
if (!props.notification?.account) {
if (!props.element?.account) {
return;
}
isWorkingOnFollowRequest.value = true;
const { data } = await client.value.rejectFollowRequest(
props.notification.account.id,
props.element.account.id,
);
relationship.value = data;
isWorkingOnFollowRequest.value = false;
};
const settings = useSettings();
const { display_name } = useParsedAccount(
props.notification?.account,
settings,
);
const { display_name } = useParsedAccount(props.element?.account, settings);
const text = computed(() => {
if (!props.notification) {
if (!props.element) {
return "";
}
switch (props.notification.type) {
switch (props.element.type) {
case "mention":
return "mentioned you";
case "reblog":
@ -95,17 +90,17 @@ const text = computed(() => {
case "follow_request":
return "requested to follow you";
default: {
console.error("Unknown notification type", props.notification.type);
console.error("Unknown notification type", props.element.type);
return "";
}
}
});
const icon = computed(() => {
if (!props.notification) {
if (!props.element) {
return "";
}
switch (props.notification.type) {
switch (props.element.type) {
case "mention":
return "tabler:at";
case "reblog":

View file

@ -1,23 +1,36 @@
<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>
<script lang="ts" setup>
import type { Status } from "@lysand-org/client/types";
import Timeline from "./timeline.vue";
const client = useClient();
const props = defineProps<{
id?: string;
id: string;
}>();
const client = useClient();
const timelineParameters = ref({});
const { timeline, loadNext, loadPrev } = useAccountTimeline(
client.value,
props.id || null,
timelineParameters,
);
const {
error,
hasReachedEnd,
isLoading,
items,
loadNext,
loadPrev,
removeItem,
updateItem,
} = useAccountTimeline(client.value, props.id);
// Example of how to handle global events
useListen("note:delete", ({ id }) => {
timeline.value = timeline.value.filter((note) => note.id !== id);
removeItem(id);
});
useListen("note:edit", (updatedNote) => {
updateItem(updatedNote);
});
</script>

View file

@ -1,18 +1,32 @@
<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>
<script lang="ts" setup>
import type { Status } from "@lysand-org/client/types";
import { useHomeTimeline } from "~/composables/HomeTimeline";
import Timeline from "./timeline.vue";
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 }) => {
timeline.value = timeline.value.filter((note) => note.id !== id);
removeItem(id);
});
useListen("note:edit", (updatedNote) => {
updateItem(updatedNote);
});
</script>

View file

@ -1,18 +1,32 @@
<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>
<script lang="ts" setup>
import type { Status } from "@lysand-org/client/types";
import { useLocalTimeline } from "~/composables/LocalTimeline";
import Timeline from "./timeline.vue";
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 }) => {
timeline.value = timeline.value.filter((note) => note.id !== id);
removeItem(id);
});
useListen("note:edit", (updatedNote) => {
updateItem(updatedNote);
});
</script>

View file

@ -1,47 +1,24 @@
<template>
<Notif v-for="notif of timeline" :key="notif.id" :notification="notif" />
<span ref="skeleton"></span>
<Notif v-for="index 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">
<iconify-icon name="tabler:message-off" width="1.5rem" height="1.5rem" />
<span>No more notifications, you've seen them all</span>
</div>
<Timeline type="notification" :items="(items as Notification[])" :is-loading="isLoading"
:has-reached-end="hasReachedEnd" :error="error" :load-next="loadNext" :load-prev="loadPrev"
:remove-item="removeItem" :update-item="updateItem" />
</template>
<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 isLoading = ref(true);
const timelineParameters = ref({});
const hasReachedEnd = ref(false);
const { timeline, loadNext, loadPrev } = useNotificationTimeline(
client.value,
timelineParameters,
);
const skeleton = ref<HTMLSpanElement | null>(null);
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;
}
});
const {
error,
hasReachedEnd,
isLoading,
items,
loadNext,
loadPrev,
removeItem,
updateItem,
} = useNotificationTimeline(client.value);
</script>

View file

@ -1,18 +1,32 @@
<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>
<script lang="ts" setup>
import type { Status } from "@lysand-org/client/types";
import { usePublicTimeline } from "~/composables/PublicTimeline";
import Timeline from "./timeline.vue";
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 }) => {
timeline.value = timeline.value.filter((note) => note.id !== id);
removeItem(id);
});
useListen("note:edit", (updatedNote) => {
updateItem(updatedNote);
});
</script>

View 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>

View file

@ -1,69 +1,92 @@
<!-- Timeline.vue -->
<template>
<TransitionGroup leave-active-class="ease-in duration-200" leave-from-class="scale-100 opacity-100"
leave-to-class="opacity-0 scale-90">
<Note v-for="note of timeline" :key="note.id" :note="note" />
</TransitionGroup>
<span ref="skeleton"></span>
<Note v-for="_ of 5" v-if="!hasReachedEnd" :skeleton="true" />
<div class="timeline">
<TransitionGroup name="timeline-item" tag="div" class="timeline-items">
<TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem"
@delete="removeItem" />
</TransitionGroup>
<div v-if="hasReachedEnd" class="text-center flex flex-row justify-center items-center py-10 text-gray-400 gap-3">
<iconify-icon icon="tabler:message-off" width="1.5rem" height="1.5rem" />
<span>No more posts, you've seen them all</span>
<TimelineItem v-if="isLoading" :type="type" v-for="_ in 5" />
<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>
</template>
<script lang="ts" setup>
import type { Status } from "@lysand-org/client/types";
import Note from "../social-elements/notes/note.vue";
import type { Notification, Status } from "@lysand-org/client/types";
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<{
timeline: Status[];
loadNext: () => Promise<void>;
loadPrev: () => Promise<void>;
items: Status[] | Notification[];
type: "status" | "notification";
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 skeleton = ref<HTMLSpanElement | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
onMounted(() => {
useIntersectionObserver(skeleton, async (entries) => {
if (
entries[0]?.isIntersecting &&
!hasReachedEnd.value &&
!isLoading.value
) {
isLoading.value = true;
await props.loadNext();
}
});
useIntersectionObserver(loadMoreTrigger, ([observer]) => {
if (observer?.isIntersecting && !props.isLoading && !props.hasReachedEnd) {
props.loadNext();
}
});
useListen("composer:send", () => {
props.loadPrev();
});
// Every 5 seconds, load newer posts (prev)
useIntervalFn(() => {
props.loadPrev();
}, 10000);
const infiniteScroll = useSetting(SettingIds.InfiniteScroll);
watch(
() => props.timeline,
(newTimeline, oldTimeline) => {
// If posts are deleted, don't start loading more posts
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;
}
() => props.items,
() => {
emit("update");
},
);
</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>