feat: Refactor notifications

This commit is contained in:
Jesse Wierzbinski 2024-12-02 12:33:53 +01:00
parent d32f4d6899
commit a6c5093cf5
No known key found for this signature in database
12 changed files with 213 additions and 453 deletions

32
app.vue
View file

@ -1,19 +1,21 @@
<template> <template>
<ClientOnly> <TooltipProvider>
<Component is="style"> <ClientOnly>
{{ customCss.value }} <Component is="style">
</Component> {{ customCss.value }}
</ClientOnly> </Component>
<NuxtPwaAssets /> </ClientOnly>
<ClientOnly> <NuxtPwaAssets />
<NuxtLayout> <ClientOnly>
<NuxtPage /> <NuxtLayout>
</NuxtLayout> <NuxtPage />
<NotificationsRenderer /> </NuxtLayout>
<ConfirmationModal /> <NotificationsRenderer />
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 --> <ConfirmationModal />
<Toaster class="pointer-events-auto" /> <!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
</ClientOnly> <Toaster class="pointer-events-auto" />
</ClientOnly>
</TooltipProvider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View file

@ -16,31 +16,27 @@
</div> </div>
<DialogFooter class="items-center flex-row"> <DialogFooter class="items-center flex-row">
<TooltipProvider> <Tooltip>
<Tooltip> <TooltipTrigger as="div">
<TooltipTrigger as="div"> <Button variant="ghost" size="icon">
<Button variant="ghost" size="icon"> <AtSign class="!size-5" />
<AtSign class="!size-5" /> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> <p>Mention someone</p>
<p>Mention someone</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> <Tooltip>
</TooltipProvider> <TooltipTrigger as="div">
<TooltipProvider> <Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
<Tooltip> @update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
<TooltipTrigger as="div"> <LetterText class="!size-5" />
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'" </Toggle>
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'"> </TooltipTrigger>
<LetterText class="!size-5" /> <TooltipContent>
</Toggle> <p>Enable Markdown</p>
</TooltipTrigger> </TooltipContent>
<TooltipContent> </Tooltip>
<p>Enable Markdown</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Select v-model:model-value="state.visibility"> <Select v-model:model-value="state.visibility">
<SelectTrigger :as-child="true" :disabled="relation?.type === 'edit'"> <SelectTrigger :as-child="true" :disabled="relation?.type === 'edit'">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -59,42 +55,36 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<TooltipProvider> <Tooltip>
<Tooltip> <TooltipTrigger as="div">
<TooltipTrigger as="div"> <Button variant="ghost" size="icon">
<Button variant="ghost" size="icon"> <Smile class="!size-5" />
<Smile class="!size-5" /> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> <p>Insert emoji</p>
<p>Insert emoji</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> <Tooltip>
</TooltipProvider> <TooltipTrigger as="div">
<TooltipProvider> <Button variant="ghost" size="icon" @click="fileInput?.click()">
<Tooltip> <FilePlus2 class="!size-5" />
<TooltipTrigger as="div"> </Button>
<Button variant="ghost" size="icon" @click="fileInput?.click()"> </TooltipTrigger>
<FilePlus2 class="!size-5" /> <TooltipContent>
</Button> <p>Attach a file</p>
</TooltipTrigger> </TooltipContent>
<TooltipContent> </Tooltip>
<p>Attach a file</p> <Tooltip>
</TooltipContent> <TooltipTrigger as="div">
</Tooltip> <Toggle variant="default" size="sm" v-model:pressed="state.sensitive">
</TooltipProvider> <TriangleAlert class="!size-5" />
<TooltipProvider> </Toggle>
<Tooltip> </TooltipTrigger>
<TooltipTrigger as="div"> <TooltipContent>
<Toggle variant="default" size="sm" v-model:pressed="state.sensitive"> <p>Mark as sensitive</p>
<TriangleAlert class="!size-5" /> </TooltipContent>
</Toggle> </Tooltip>
</TooltipTrigger>
<TooltipContent>
<p>Mark as sensitive</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit"> <Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
<Loader v-if="sending" class="!size-5 animate-spin" /> <Loader v-if="sending" class="!size-5 animate-spin" />
{{ relation?.type === "edit" ? "Save" : "Send" }} {{ relation?.type === "edit" ? "Save" : "Send" }}

View file

@ -1,25 +0,0 @@
<template>
<div v-if="identity" class="bg-dark-800 z-0 p-6 my-5 relative overflow-hidden rounded ring-1 ring-white/5">
<div class="sm:flex sm:items-center sm:justify-between gap-3">
<div class="sm:flex sm:space-x-5 grow">
<Avatar :src="identity.account.avatar"
class="mx-auto shrink-0 size-20 rounded overflow-hidden ring-1 ring-white/10"
:alt="'Your avatar'" />
<div
class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10">
<p class="text-sm font-medium text-gray-300">Welcome back,</p>
<p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1" v-html="display_name"></p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Avatar from "../avatars/avatar.vue";
const { display_name } = useParsedAccount(
computed(() => identity.value?.account),
settings,
);
</script>

View file

@ -1,25 +0,0 @@
import {
confirmModalService,
confirmModalWithInputService,
} from "./service.ts";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
export function useConfirmModal() {
const confirm = (
options: ConfirmModalOptions,
): Promise<ConfirmModalResult> => {
return confirmModalService.confirm(options);
};
const confirmWithInput = (
options: ConfirmModalOptions,
placeholder?: string,
): Promise<ConfirmModalResult> => {
return confirmModalWithInputService.confirm(options, placeholder);
};
return {
confirm,
confirmWithInput,
};
}

View file

@ -1,124 +0,0 @@
<template>
<HeadlessTransitionRoot as="template" :show="isOpen">
<Dialog.Root :open="isOpen" @update:open="handleOpenChange" :close-on-escape="true"
:close-on-interact-outside="true">
<Teleport to="body">
<Dialog.Positioner class="fixed inset-0 z-50 flex items-end md:items-center justify-center md:p-4">
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0"
enter-to="opacity-100" leave="ease-in duration-300" leave-from="opacity-100"
leave-to="opacity-0">
<Dialog.Backdrop class="fixed inset-0 bg-black/70 backdrop-blur-sm" />
</HeadlessTransitionChild>
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100" leave="ease-in duration-300" leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95">
<Dialog.Content class="relative w-full md:max-w-md p-6 rounded bg-dark-800 ring-1 ring-white/10 shadow-xl">
<Dialog.Title class="mb-4 text-lg font-bold tracking-tight text-gray-100 sm:text-xl">
{{ modalOptions.title || 'Confirm Action' }}
</Dialog.Title>
<div class="mb-6 text-gray-300">
{{ modalOptions.message }}
</div>
<div v-if="withInput" class="mb-4">
<input v-model="inputValue" type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder="inputPlaceholder" />
</div>
<div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-3 *:!py-2">
<Button @click="handleCancel"
theme="outline">
{{ modalOptions.cancelText || 'Cancel' }}
</button>
<Button @click="handleConfirm"
theme="primary">
{{ modalOptions.confirmText || 'Confirm' }}
</button>
</div>
</Dialog.Content>
</HeadlessTransitionChild>
</Dialog.Positioner>
</Teleport>
</Dialog.Root>
</HeadlessTransitionRoot>
</template>
<script setup lang="ts">
import { Dialog } from "@ark-ui/vue";
import Button from "~/packages/ui/components/buttons/button.vue";
import {
confirmModalService,
confirmModalWithInputService,
} from "./service.ts";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
const isOpen = ref(false);
const modalOptions = ref<ConfirmModalOptions>({ message: "" });
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
const inputValue = ref("");
const withInput = ref(false);
const inputPlaceholder = ref("");
const open = async (
options: ConfirmModalOptions,
): Promise<ConfirmModalResult> => {
modalOptions.value = options;
isOpen.value = true;
withInput.value = false;
inputValue.value = "";
return new Promise((resolve) => {
resolvePromise.value = resolve;
});
};
const openWithInput = async (
options: ConfirmModalOptions,
placeholder = "Enter value",
): Promise<ConfirmModalResult> => {
modalOptions.value = options;
isOpen.value = true;
withInput.value = true;
inputValue.value = "";
inputPlaceholder.value = placeholder;
return new Promise((resolve) => {
resolvePromise.value = resolve;
});
};
const handleConfirm = () => {
if (resolvePromise.value) {
resolvePromise.value({
confirmed: true,
value: withInput.value ? inputValue.value : undefined,
});
isOpen.value = false;
}
};
const handleCancel = () => {
if (resolvePromise.value) {
resolvePromise.value({ confirmed: false });
isOpen.value = false;
}
};
const handleOpenChange = (open: boolean) => {
if (!open && resolvePromise.value) {
resolvePromise.value({ confirmed: false });
isOpen.value = false;
}
};
// Register the component with the service
confirmModalService.register({
open,
});
confirmModalWithInputService.register({
open: openWithInput,
});
</script>

View file

@ -1,52 +0,0 @@
import { ref } from "vue";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
class ConfirmModalService {
private modalRef = ref<{
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
} | null>(null);
register(modal: {
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
}) {
this.modalRef.value = modal;
}
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
if (!this.modalRef.value) {
throw new Error("Confirmation modal not initialized");
}
return this.modalRef.value.open(options);
}
}
class ConfirmModalWithInputService {
private modalRef = ref<{
open: (
options: ConfirmModalOptions,
placeholder?: string,
) => Promise<ConfirmModalResult>;
} | null>(null);
register(modal: {
open: (
options: ConfirmModalOptions,
placeholder?: string,
) => Promise<ConfirmModalResult>;
}) {
this.modalRef.value = modal;
}
confirm(
options: ConfirmModalOptions,
placeholder?: string,
): Promise<ConfirmModalResult> {
if (!this.modalRef.value) {
throw new Error("Confirmation modal not initialized");
}
return this.modalRef.value.open(options, placeholder);
}
}
export const confirmModalService = new ConfirmModalService();
export const confirmModalWithInputService = new ConfirmModalWithInputService();

View file

@ -1,11 +0,0 @@
export interface ConfirmModalOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
}
export interface ConfirmModalResult {
confirmed: boolean;
value?: string;
}

View file

@ -0,0 +1,87 @@
<template>
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
<NuxtLink class="relative size-10">
<Avatar class="size-10 rounded border border-border">
<AvatarImage :src="follower.avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
</NuxtLink>
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight text-sm">
<span class="truncate font-semibold">{{
follower.display_name
}}</span>
<span class="truncate tracking-tight">
<CopyableText :text="follower.acct">
<span
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }}
</span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText>
</span>
</div>
</div>
<div v-if="loading" class="flex p-2 items-center justify-center h-12">
<Loader class="size-4 animate-spin" />
</div>
<div v-else-if="relationship?.requested_by === false" class="flex p-2 items-center justify-center h-12">
<Check class="size-4" />
</div>
<div v-else class="grid grid-cols-2 p-2 gap-2">
<Button variant="outline" size="sm" @click="accept">
<Check />
Accept
</Button>
<Button variant="ghost" size="sm" @click="reject">
<X />
Reject
</Button>
</div>
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import { Check, Loader, X } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
const { follower } = defineProps<{
follower: Account;
}>();
const loading = ref(true);
const [username, instance] = follower.acct.split("@");
const { relationship } = useRelationship(client, follower.id);
// TODO: Add "followed" notification
watch(relationship, () => {
if (relationship.value) {
loading.value = false;
}
});
const accept = async () => {
const id = toast.loading("Accepting follow request...");
loading.value = true;
const { data } = await client.value.acceptFollowRequest(follower.id);
toast.dismiss(id);
toast.success("Follow request accepted.");
relationship.value = data;
loading.value = false;
};
const reject = async () => {
const id = toast.loading("Rejecting follow request...");
loading.value = true;
const { data } = await client.value.rejectFollowRequest(follower.id);
toast.dismiss(id);
toast.success("Follow request rejected.");
relationship.value = data;
loading.value = false;
};
</script>

View file

@ -1,32 +1,45 @@
<template> <template>
<TooltipProvider> <Card>
<Card> <Collapsible>
<Tooltip> <Tooltip>
<TooltipTrigger :as-child="true"> <TooltipTrigger :as-child="true">
<CardHeader v-if="notification.account" class="flex-row items-center gap-2 p-4"> <CardHeader v-if="notification.account"
<component :is="icon" class="size-5" /> class="flex-row items-center gap-2 px-4 py-2 border-b border-border">
<component :is="icon" class="size-5 shrink-0" />
<Avatar class="size-6 rounded-md border border-card"> <Avatar class="size-6 rounded-md border border-card">
<AvatarImage :src="notification.account.avatar" alt="" /> <AvatarImage :src="notification.account.avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar> </Avatar>
<span class="font-semibold">{{ notification.type === 'mention' ? text.toLowerCase() : notification.account.display_name }}</span> <span class="font-semibold">{{
notification.account.display_name
}}</span>
<CollapsibleTrigger :as-child="true">
<Button variant="ghost" size="icon" class="ml-auto [&_svg]:data-[state=open]:-rotate-180">
<ChevronDown class="duration-200" />
</Button>
</CollapsibleTrigger>
</CardHeader> </CardHeader>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{{ text }}</p> <p>{{ text }}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<CardContent class="p-0"> <CollapsibleContent :as-child="true">
<Note v-if="notification.status" :note="notification.status" :small-layout="true" :hide-actions="true" /> <CardContent class="p-0">
</CardContent> <Note v-if="notification.status" :note="notification.status" :small-layout="true"
</Card> :hide-actions="true" />
</TooltipProvider> <FollowRequest v-else-if="notification.type === 'follow_request' && notification.account" :follower="notification.account" />
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Notification } from "@versia/client/types"; import type { Notification } from "@versia/client/types";
import { import {
AtSign, AtSign,
ChevronDown,
Heart, Heart,
Repeat, Repeat,
User, User,
@ -34,14 +47,20 @@ import {
UserPlus, UserPlus,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { Card, CardContent, CardHeader } from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "~/components/ui/tooltip"; } from "~/components/ui/tooltip";
import Note from "../notes/note.vue"; import Note from "../notes/note.vue";
import FollowRequest from "./follow-request.vue";
const { notification } = defineProps<{ const { notification } = defineProps<{
notification: Notification; notification: Notification;
@ -84,4 +103,4 @@ const text = computed(() => {
return ""; return "";
} }
}); });
</script> </script>

View file

@ -275,7 +275,7 @@ const instance = useInstance();
<slot /> <slot />
</div> </div>
</SidebarInset> </SidebarInset>
<Sidebar variant="inset" collapsible="none" side="right" class="[--sidebar-width:24rem]"> <Sidebar variant="inset" collapsible="none" side="right" class="[--sidebar-width:24rem] hidden lg:flex">
<SidebarContent class="p-2 overflow-y-auto"> <SidebarContent class="p-2 overflow-y-auto">
<NotificationsTimeline /> <NotificationsTimeline />
</SidebarContent> </SidebarContent>

View file

@ -1,116 +0,0 @@
<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="!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="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="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="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>
</Button>
<Button theme="secondary" :loading="isWorkingOnFollowRequest"
@click="rejectFollowRequest"><span>Reject</span>
</Button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Notification } from "@versia/client/types";
import Avatar from "~/components/avatars/avatar.vue";
import Skeleton from "~/components/skeleton/Skeleton.vue";
import Button from "~/packages/ui/components/buttons/button.vue";
//import Note from "../notes/note.vue";
import SmallCard from "../users/SmallCard.vue";
const props = defineProps<{
element?: Notification;
}>();
const isWorkingOnFollowRequest = ref(false);
const { relationship } = useRelationship(
client,
props.element?.account?.id ?? null,
);
const acceptFollowRequest = async () => {
if (!props.element?.account) {
return;
}
isWorkingOnFollowRequest.value = true;
const { data } = await client.value.acceptFollowRequest(
props.element.account.id,
);
relationship.value = data;
isWorkingOnFollowRequest.value = false;
};
const rejectFollowRequest = async () => {
if (!props.element?.account) {
return;
}
isWorkingOnFollowRequest.value = true;
const { data } = await client.value.rejectFollowRequest(
props.element.account.id,
);
relationship.value = data;
isWorkingOnFollowRequest.value = false;
};
const { display_name } = useParsedAccount(props.element?.account, settings);
const text = computed(() => {
if (!props.element) {
return "";
}
switch (props.element.type) {
case "mention":
return "mentioned you";
case "reblog":
return "reblogged your note";
case "favourite":
return "liked your note";
case "follow":
return "followed you";
case "follow_request":
return "requested to follow you";
default: {
console.error("Unknown notification type", props.element.type);
return "";
}
}
});
const icon = computed(() => {
if (!props.element) {
return "";
}
switch (props.element.type) {
case "mention":
return "tabler:at";
case "reblog":
return "tabler:repeat";
case "favourite":
return "tabler:heart";
case "follow":
return "tabler:plus";
case "follow_request":
return "tabler:plus";
default:
return "";
}
});
</script>

View file

@ -1,7 +1,8 @@
<!-- Timeline.vue --> <!-- Timeline.vue -->
<template> <template>
<div class="timeline rounded"> <div class="timeline rounded">
<TransitionGroup name="timeline-item" tag="div" class="timeline-items *:rounded space-y-4 *:border *:border-border/50"> <TransitionGroup name="timeline-item" tag="div"
class="timeline-items *:rounded space-y-4 *:border *:border-border/50">
<TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem" <TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem"
@delete="removeItem" /> @delete="removeItem" />
</TransitionGroup> </TransitionGroup>
@ -12,17 +13,25 @@
{{ error.message }} {{ error.message }}
</div> </div>
<div v-if="hasReachedEnd && items.length > 0" <!-- If there are some posts, but the user scrolled to the end -->
class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10"> <Card v-if="hasReachedEnd && items.length > 0" class="shadow-none bg-transparent border-none p-4">
<span class="text-lg font-semibold">You've scrolled so far, there's nothing left to show.</span> <CardHeader class="text-center gap-y-4">
<span class="text-sm">You can always go back and see what you missed.</span> <CardTitle class="text-">No more data.</CardTitle>
</div> <CardDescription>
You've scrolled so far, there's nothing left to show.
</CardDescription>
</CardHeader>
</Card>
<div v-else-if="hasReachedEnd && items.length === 0" <!-- If there are no posts at all -->
class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10"> <Card v-else-if="hasReachedEnd && items.length === 0" class="shadow-none bg-transparent border-none p-4">
<span class="text-lg font-semibold">There's nothing to show here.</span> <CardHeader class="text-center gap-y-4">
<span class="text-sm">Either you're all caught up or there's nothing to show.</span> <CardTitle class="text-">There's nothing to show here.</CardTitle>
</div> <CardDescription>
Either you're all caught up or there's nothing to show.
</CardDescription>
</CardHeader>
</Card>
<div v-else-if="!infiniteScroll.value" class="py-10 px-4"> <div v-else-if="!infiniteScroll.value" class="py-10 px-4">
<Button theme="secondary" @click="loadNext" :disabled="isLoading" class="w-full"> <Button theme="secondary" @click="loadNext" :disabled="isLoading" class="w-full">
@ -38,6 +47,12 @@
import type { Notification, Status } from "@versia/client/types"; import type { Notification, Status } from "@versia/client/types";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import { onMounted, watch } from "vue"; import { onMounted, watch } from "vue";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import Button from "~/packages/ui/components/buttons/button.vue"; import Button from "~/packages/ui/components/buttons/button.vue";
import { SettingIds } from "~/settings"; import { SettingIds } from "~/settings";
import TimelineItem from "./timeline-item.vue"; import TimelineItem from "./timeline-item.vue";