chore: ⬆️ Upgrade to Nuxt 4
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 1s
Deploy to GitHub Pages / build (push) Failing after 1s
Deploy to GitHub Pages / deploy (push) Has been skipped
Docker / build (push) Failing after 1s
Mirror to Codeberg / Mirror (push) Failing after 1s

This commit is contained in:
Jesse Wierzbinski 2025-07-16 07:48:39 +02:00
parent 8debe97f63
commit 7f7cf20311
386 changed files with 2376 additions and 2332 deletions

View file

@ -0,0 +1,15 @@
<template>
<Button variant="ghost" class="max-w-14 w-full" size="sm">
<component :is="icon" class="size-4" />
<slot />
</Button>
</template>
<script lang="ts" setup>
import type { FunctionalComponent } from "vue";
import { Button } from "../ui/button";
const { icon } = defineProps<{
icon: FunctionalComponent;
}>();
</script>

View file

@ -0,0 +1,167 @@
<template>
<div class="flex flex-row w-full max-w-sm items-stretch justify-between">
<ActionButton :icon="Reply" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!identity">
{{ numberFormat(replyCount) }}
</ActionButton>
<ActionButton :icon="Heart" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!identity" :class="liked && '*:fill-red-600 *:text-red-600'">
{{ numberFormat(likeCount) }}
</ActionButton>
<ActionButton :icon="Repeat" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!identity" :class="reblogged && '*:text-green-600'">
{{ numberFormat(reblogCount) }}
</ActionButton>
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
<Picker @pick="react">
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" />
</Picker>
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
</Menu>
</div>
</template>
<script lang="ts" setup>
import type { CustomEmoji, Status } from "@versia/client/schemas";
import { Ellipsis, Heart, Quote, Repeat, Reply, Smile } from "lucide-vue-next";
import { toast } from "vue-sonner";
import type { z } from "zod";
import * as m from "~~/paraglide/messages.js";
import { getLocale } from "~~/paraglide/runtime";
import { confirmModalService } from "../modals/composable";
import ActionButton from "./action-button.vue";
import Menu from "./menu.vue";
import type { UnicodeEmoji } from "./reactions/picker/emoji";
import Picker from "./reactions/picker/index.vue";
const { noteId } = defineProps<{
replyCount: number;
likeCount: number;
reblogCount: number;
apiNoteString: string;
noteId: string;
isRemote: boolean;
url: string;
remoteUrl?: string;
authorId: string;
liked: boolean;
reblogged: boolean;
}>();
const emit = defineEmits<{
edit: [];
reply: [];
quote: [];
delete: [];
react: [];
}>();
const { play } = useAudio();
const like = async () => {
if (preferences.confirm_actions.value.includes("like")) {
const confirmation = await confirmModalService.confirm({
title: m.slimy_least_ray_aid(),
message: m.stale_new_ray_jolt(),
confirmText: m.royal_close_samuel_scold(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
play("like");
const id = toast.loading(m.slimy_candid_tiger_read());
const { data } = await client.value.favouriteStatus(noteId);
toast.dismiss(id);
toast.success(m.mealy_slow_buzzard_commend());
useEvent("note:edit", data);
};
const unlike = async () => {
if (preferences.confirm_actions.value.includes("like")) {
const confirmation = await confirmModalService.confirm({
title: m.odd_strong_halibut_prosper(),
message: m.slow_blue_parrot_savor(),
confirmText: m.vexed_fluffy_clownfish_dance(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.busy_active_leopard_strive());
const { data } = await client.value.unfavouriteStatus(noteId);
toast.dismiss(id);
toast.success(m.fresh_direct_bear_affirm());
useEvent("note:edit", data);
};
const reblog = async () => {
if (preferences.confirm_actions.value.includes("reblog")) {
const confirmation = await confirmModalService.confirm({
title: m.best_mellow_llama_surge(),
message: m.salty_plain_mallard_gaze(),
confirmText: m.aware_helpful_marlin_drop(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.late_sunny_cobra_scold());
const { data } = await client.value.reblogStatus(noteId);
toast.dismiss(id);
toast.success(m.weird_moving_hawk_lift());
useEvent(
"note:edit",
(data.reblog as z.infer<typeof Status> | null) || data,
);
};
const unreblog = async () => {
if (preferences.confirm_actions.value.includes("reblog")) {
const confirmation = await confirmModalService.confirm({
title: m.main_fancy_octopus_loop(),
message: m.odd_alive_swan_express(),
confirmText: m.lime_neat_ox_stab(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.white_sharp_gorilla_embrace());
const { data } = await client.value.unreblogStatus(noteId);
toast.dismiss(id);
toast.success(m.royal_polite_moose_catch());
useEvent("note:edit", data);
};
const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
const id = toast.loading(m.gray_stale_antelope_roam());
const text = (emoji as UnicodeEmoji).hexcode
? (emoji as UnicodeEmoji).unicode
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
const { data } = await client.value.createEmojiReaction(noteId, text);
toast.dismiss(id);
toast.success(m.main_least_turtle_fall());
useEvent("note:edit", data);
};
const numberFormat = (number = 0) =>
number !== 0
? new Intl.NumberFormat(getLocale(), {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(number)
: undefined;
</script>

View file

@ -0,0 +1,19 @@
<template>
<ImageAttachment v-if="attachment.type === 'image'" :attachment="attachment" />
<VideoAttachment v-else-if="attachment.type === 'video' || attachment.type === 'gifv'" :attachment="attachment" />
<AudioAttachment v-else-if="attachment.type === 'audio'" :attachment="attachment" />
<FileAttachment v-else :attachment="attachment" />
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/schemas";
import type { z } from "zod";
import AudioAttachment from "./attachments/audio.vue";
import FileAttachment from "./attachments/file.vue";
import ImageAttachment from "./attachments/image.vue";
import VideoAttachment from "./attachments/video.vue";
defineProps<{
attachment: z.infer<typeof Attachment>;
}>();
</script>

View file

@ -0,0 +1,16 @@
<template>
<!-- [&:has(>:last-child:nth-child(1))] means "when this element has 1 child" -->
<div class="grid gap-4 grid-cols-2 *:max-h-56 [&:has(>:last-child:nth-child(1))]:grid-cols-1 sm:[&:has(>:last-child:nth-child(1))>*]:max-h-72">
<Attachment v-for="attachment in attachments" :key="attachment.id" :attachment="attachment" />
</div>
</template>
<script lang="ts" setup>
import type { Attachment as AttachmentType } from "@versia/client/schemas";
import type { z } from "zod";
import Attachment from "./attachment.vue";
defineProps<{
attachments: z.infer<typeof AttachmentType>[];
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<Base :attachment="attachment">
<audio :src="attachment.url" :alt="attachment.description ?? undefined" controls />
</Base>
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/schemas";
import type { z } from "zod";
import Base from "./base.vue";
const { attachment } = defineProps<{
attachment: z.infer<typeof Attachment>;
}>();
</script>

View file

@ -0,0 +1,74 @@
<template>
<Dialog>
<Card class="w-full h-full overflow-hidden relative p-0 *:first:w-full *:first:h-full *:first:object-contain *:first:bg-muted/20">
<DialogTrigger v-if="lightbox" :as-child="true">
<slot />
</DialogTrigger>
<slot v-else />
<!-- Alt text viewer -->
<Popover v-if="attachment.description">
<div class="absolute top-0 right-0 p-2">
<PopoverTrigger :as-child="true">
<Button variant="outline" size="icon" title="View alt text">
<Captions />
</Button>
</PopoverTrigger>
</div>
<PopoverContent>
<p class="text-sm">{{ attachment.description }}</p>
</PopoverContent>
</Popover>
</Card>
<DialogContent :hide-close="true"
class="duration-200 bg-transparent border-none overflow-hidden !animate-none gap-6 w-screen h-screen !max-w-none">
<div class="grid grid-rows-[auto_1fr_auto]">
<div class="flex flex-row gap-2 w-full">
<DialogTitle class="sr-only">{{ attachment.type }}</DialogTitle>
<Button as="a" :href="attachment?.url" target="_blank" :download="true" variant="outline" size="icon"
class="ml-auto">
<Download />
</Button>
<DialogClose :as-child="true">
<Button variant="outline" size="icon">
<X />
</Button>
</DialogClose>
</div>
<div class="flex items-center justify-center overflow-hidden *:max-h-[80vh] *:max-w-[80vw] *:w-full *:h-full *:object-contain">
<slot />
</div>
<DialogDescription class="flex items-center justify-center">
<Card v-if="attachment.description" class="max-w-md max-h-48 overflow-auto text-sm">
<p>{{ attachment.description }}</p>
</Card>
</DialogDescription>
</div>
</DialogContent>
</Dialog>
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/schemas";
import { Captions, Download, File, X } from "lucide-vue-next";
import type { z } from "zod";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
const { attachment, lightbox = false } = defineProps<{
attachment: z.infer<typeof Attachment>;
lightbox?: boolean;
}>();
</script>

View file

@ -0,0 +1,19 @@
<template>
<Base :attachment="attachment" lightbox>
<div class="flex flex-col items-center justify-center min-h-48 text-sm gap-2">
<File class="size-12" />
<span>File attachment</span>
</div>
</Base>
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/schemas";
import { File } from "lucide-vue-next";
import type { z } from "zod";
import Base from "./base.vue";
const { attachment } = defineProps<{
attachment: z.infer<typeof Attachment>;
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<Base :attachment="attachment" lightbox>
<img :src="attachment.url" :alt="attachment.description ?? undefined" />
</Base>
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/schemas";
import type { z } from "zod";
import Base from "./base.vue";
const { attachment } = defineProps<{
attachment: z.infer<typeof Attachment>;
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<Base :attachment="attachment">
<video :src="attachment.url" :alt="attachment.description ?? undefined" controls />
</Base>
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/schemas";
import type { z } from "zod";
import Base from "./base.vue";
const { attachment } = defineProps<{
attachment: z.infer<typeof Attachment>;
}>();
</script>

View file

@ -0,0 +1,23 @@
<template>
<Alert layout="button">
<TriangleAlert />
<AlertTitle>{{ contentWarning || m.sour_seemly_bird_hike() }}</AlertTitle>
<Button @click="blurred = !blurred" variant="outline" size="sm">{{ blurred ? m.bald_direct_turtle_win() :
m.known_flaky_cockroach_dash() }}</Button>
</Alert>
</template>
<script lang="ts" setup>
import { TriangleAlert } from "lucide-vue-next";
import * as m from "~~/paraglide/messages.js";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Button } from "../ui/button";
const { contentWarning } = defineProps<{
contentWarning?: string;
}>();
const blurred = defineModel<boolean>({
default: true,
});
</script>

View file

@ -0,0 +1,37 @@
<template>
<ContentWarning v-if="(sensitive || contentWarning) && preferences.show_content_warning" :content-warning="contentWarning" v-model="blurred" />
<OverflowGuard v-if="content" :character-count="characterCount" :class="(blurred && preferences.show_content_warning) && 'blur-md'">
<Prose v-html="content" v-render-emojis="emojis"></Prose>
</OverflowGuard>
<Attachments v-if="attachments.length > 0" :attachments="attachments" :class="(blurred && preferences.show_content_warning) && 'blur-xl'" />
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
<Note :note="quote" :hide-actions="true" :small-layout="true" />
</div>
</template>
<script lang="ts" setup>
import type { Attachment, CustomEmoji, Status } from "@versia/client/schemas";
import type { z } from "zod";
import Attachments from "./attachments.vue";
import ContentWarning from "./content-warning.vue";
import Note from "./note.vue";
import OverflowGuard from "./overflow-guard.vue";
import Prose from "./prose.vue";
const { content, plainContent, sensitive, contentWarning } = defineProps<{
plainContent?: string;
content: string;
quote?: NonNullable<z.infer<typeof Status.shape.quote>>;
emojis: z.infer<typeof CustomEmoji>[];
attachments: z.infer<typeof Attachment>[];
sensitive: boolean;
contentWarning?: string;
}>();
const blurred = ref(sensitive || !!contentWarning);
const characterCount = plainContent?.length;
</script>

View file

@ -0,0 +1,113 @@
<template>
<div class="rounded grid grid-cols-[auto_1fr_auto] items-center gap-3">
<HoverCard v-model:open="popupOpen" @update:open="() => {
if (!preferences.popup_avatar_hover) {
popupOpen = false;
}
}" :open-delay="2000">
<HoverCardTrigger :as-child="true">
<NuxtLink :href="urlAsPath" :class="cn('relative size-12', smallLayout && 'size-8')">
<Avatar :class="cn('size-12 border border-card', smallLayout && 'size-8')" :src="author.avatar"
:name="author.display_name" />
<Avatar v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1" :src="cornerAvatar" />
</NuxtLink>
</HoverCardTrigger>
<HoverCardContent class="w-96">
<SmallCard :account="author" />
</HoverCardContent>
</HoverCard>
<Col
:class="smallLayout && 'text-sm'">
<Text class="font-semibold" v-render-emojis="author.emojis">{{
author.display_name
}}</Text>
<div class="-mt-1">
<Address as="span" :username="username" :domain="instance" />
&middot;
<Text as="span" muted class="ml-auto tracking-normal" :title="fullTime">{{ timeAgo }}</Text>
</div>
</Col>
<div v-if="!smallLayout">
<NuxtLink :href="noteUrlAsPath" class="text-xs text-muted-foreground"
:title="visibilities[visibility].text">
<component :is="visibilities[visibility].icon" class="size-4" />
</NuxtLink>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Account, Status } from "@versia/client/schemas";
import type {
UseTimeAgoMessages,
UseTimeAgoUnitNamesDefault,
} from "@vueuse/core";
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
import type { z } from "zod";
import { cn } from "@/lib/utils";
import { getLocale } from "~~/paraglide/runtime";
import Address from "../profiles/address.vue";
import Avatar from "../profiles/avatar.vue";
import SmallCard from "../profiles/small-card.vue";
import Col from "../typography/layout/col.vue";
import Text from "../typography/text.vue";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
cornerAvatar?: string;
visibility: z.infer<typeof Status.shape.visibility>;
noteUrl: string;
createdAt: Date;
smallLayout?: boolean;
author: z.infer<typeof Account>;
authorUrl: string;
}>();
const [username, instance] = author.acct.split("@");
const digitRegex = /\d/;
const urlAsPath = new URL(authorUrl).pathname;
const noteUrlAsPath = new URL(noteUrl).pathname;
const timeAgo = useTimeAgo(createdAt, {
messages: {
justNow: "now",
past: (n) => (n.match(digitRegex) ? `${n}` : n),
future: (n) => (n.match(digitRegex) ? `in ${n}` : n),
month: (n) => `${n}mo`,
year: (n) => `${n}y`,
day: (n) => `${n}d`,
week: (n) => `${n}w`,
hour: (n) => `${n}h`,
minute: (n) => `${n}m`,
second: (n) => `${n}s`,
invalid: "",
} as UseTimeAgoMessages<UseTimeAgoUnitNamesDefault>,
});
const fullTime = new Intl.DateTimeFormat(getLocale(), {
dateStyle: "medium",
timeStyle: "short",
}).format(createdAt);
const popupOpen = ref(false);
const visibilities = {
public: {
icon: Globe,
text: "This note is public: it can be seen by anyone.",
},
unlisted: {
icon: LockOpen,
text: "This note is unlisted: it can be seen by anyone with the link.",
},
private: {
icon: Lock,
text: "This note is private: it can only be seen by followers.",
},
direct: {
icon: AtSign,
text: "This note is direct: it can only be seen by mentioned users.",
},
};
</script>

View file

@ -0,0 +1,138 @@
<script setup lang="tsx">
import {
Ban,
Code,
Delete,
ExternalLink,
Flag,
Hash,
Link,
Pencil,
Trash,
} from "lucide-vue-next";
import { toast } from "vue-sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { confirmModalService } from "~/components/modals/composable.ts";
import * as m from "~~/paraglide/messages.js";
const { authorId, noteId } = defineProps<{
apiNoteString: string;
isRemote: boolean;
url: string;
remoteUrl?: string;
authorId: string;
noteId: string;
}>();
const emit = defineEmits<{
edit: [];
delete: [];
}>();
const { copy } = useClipboard();
const loggedIn = !!identity.value;
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
const copyText = (text: string) => {
copy(text);
toast.success(m.flat_nice_worm_dream());
};
const blockUser = async (userId: string) => {
const id = toast.loading(m.top_cute_bison_nudge());
await client.value.blockAccount(userId);
toast.dismiss(id);
toast.success(m.main_weary_racoon_peek());
};
const _delete = async () => {
if (preferences.confirm_actions.value.includes("delete")) {
const confirmation = await confirmModalService.confirm({
title: m.calm_icy_weasel_twirl(),
message: m.gray_fun_toucan_slide(),
confirmText: m.royal_best_tern_transform(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.new_funny_fox_boil());
await client.value.deleteStatus(noteId);
toast.dismiss(id);
toast.success(m.green_tasty_bumblebee_beam());
emit("delete");
};
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-56">
<DropdownMenuGroup>
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
<Pencil />
{{ m.front_lime_grizzly_persist() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
<Code />
{{ m.yummy_moving_scallop_sail() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(noteId)">
<Hash />
{{ m.sunny_zany_jellyfish_pop() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(url)">
<Link />
{{ m.ago_new_pelican_drip() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" v-if="isRemote && remoteUrl" @click="copyText(remoteUrl)">
<Link />
{{ m.solid_witty_zebra_walk() }}
</DropdownMenuItem>
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
<ExternalLink />
{{ m.active_trite_lark_inspire() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="authorIsMe" />
<DropdownMenuGroup v-if="authorIsMe">
<DropdownMenuItem as="button" :disabled="true">
<Delete />
{{ m.real_green_clownfish_pet() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="_delete">
<Trash />
{{ m.tense_quick_cod_favor() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
<DropdownMenuItem as="button" :disabled="true">
<Flag />
{{ m.great_few_jaguar_rise() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="blockUser(authorId)">
<Ban />
{{ m.misty_soft_sparrow_vent() }}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -0,0 +1,110 @@
<template>
<Card as="article" class="relative gap-3 items-stretch">
<CardHeader as="header" class="space-y-2">
<ReblogHeader
v-if="note.reblog"
:avatar="note.account.avatar"
:display-name="note.account.display_name"
:url="reblogAccountUrl"
:emojis="note.account.emojis"
/>
<Header
:author="noteToUse.account"
:author-url="accountUrl"
:corner-avatar="note.reblog ? note.account.avatar : undefined"
:note-url="url"
:visibility="noteToUse.visibility"
:created-at="new Date(noteToUse.created_at)"
:small-layout="smallLayout"
class="z-[1]"
/>
<div
v-if="topAvatarBar"
:class="
cn(
'shrink-0 bg-border w-0.5 absolute top-0 h-7 left-[3rem]'
)
"
></div>
<div
v-if="bottomAvatarBar"
:class="
cn(
'shrink-0 bg-border w-0.5 absolute bottom-0 h-[calc(100%-1.5rem)] left-[3rem]'
)
"
></div>
</CardHeader>
<!-- Simply offset by the size of avatar + 0.75rem (the gap) -->
<CardContent
:class="
['space-y-4', contentUnderUsername && (smallLayout ? 'ml-11' : 'ml-[4.25rem]')]
"
>
<Content
:content="noteToUse.content"
:quote="note.quote ?? undefined"
:attachments="noteToUse.media_attachments"
:plain-content="noteToUse.text ?? undefined"
:emojis="noteToUse.emojis"
:sensitive="noteToUse.sensitive"
:content-warning="noteToUse.spoiler_text"
/>
<Reactions v-if="noteToUse.reactions && noteToUse.reactions.length > 0" :reactions="noteToUse.reactions" :emojis="noteToUse.emojis" :status-id="noteToUse.id" />
</CardContent>
<CardFooter v-if="!hideActions">
<Actions
:reply-count="noteToUse.replies_count"
:like-count="noteToUse.favourites_count"
:url="url"
:api-note-string="JSON.stringify(noteToUse, null, 4)"
:reblog-count="noteToUse.reblogs_count"
:remote-url="noteToUse.url ?? undefined"
:is-remote="isRemote"
:author-id="noteToUse.account.id"
@edit="useEvent('composer:edit', noteToUse)"
@reply="useEvent('composer:reply', noteToUse)"
@quote="useEvent('composer:quote', noteToUse)"
@delete="useEvent('note:delete', noteToUse)"
:note-id="noteToUse.id"
:liked="noteToUse.favourited ?? false"
:reblogged="noteToUse.reblogged ?? false"
/>
</CardFooter>
</Card>
</template>
<script setup lang="ts">
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardFooter, CardHeader } from "../ui/card";
import Actions from "./actions.vue";
import Content from "./content.vue";
import Header from "./header.vue";
import Reactions from "./reactions/index.vue";
import ReblogHeader from "./reblog-header.vue";
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
const { note } = defineProps<{
note: PartialBy<z.infer<typeof Status>, "reblog" | "quote">;
hideActions?: boolean;
smallLayout?: boolean;
contentUnderUsername?: boolean;
topAvatarBar?: boolean;
bottomAvatarBar?: boolean;
}>();
// Notes can be reblogs, in which case the actual thing to render is inside the reblog property
const noteToUse = computed(() =>
note.reblog
? (note.reblog as z.infer<typeof Status>)
: (note as z.infer<typeof Status>),
);
const url = wrapUrl(`/@${noteToUse.value.account.acct}/${noteToUse.value.id}`);
const accountUrl = wrapUrl(`/@${noteToUse.value.account.acct}`);
const reblogAccountUrl = wrapUrl(`/@${note.account.acct}`);
const isRemote = noteToUse.value.account.acct.includes("@");
</script>

View file

@ -0,0 +1,49 @@
<template>
<div ref="container" class="overflow-y-hidden relative duration-200" :style="{
maxHeight: collapsed ? '18rem' : `${container?.scrollHeight}px`,
}">
<slot />
<div v-if="isOverflowing && collapsed"
class="absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/5 to-transparent rounded-b"></div>
<Button v-if="isOverflowing" @click="collapsed = !collapsed"
class="absolute bottom-2 right-1/2 translate-x-1/2">{{
collapsed
? `${m.lazy_honest_mammoth_bump()}${formattedCharacterCount ? `${m.dark_spare_goldfish_charm({
count: formattedCharacterCount,
})}` : ""}`
: m.that_misty_mule_arrive()
}}</Button>
</div>
</template>
<script lang="ts" setup>
import * as m from "~~/paraglide/messages.js";
import { getLocale } from "~~/paraglide/runtime";
import { Button } from "../ui/button";
const { characterCount = 0 } = defineProps<{
characterCount?: number;
}>();
const container = useTemplateRef<HTMLDivElement>("container");
const collapsed = ref(true);
// max-h-72 is 18rem
const remToPx = (rem: number) =>
rem *
Number.parseFloat(
getComputedStyle(document.documentElement).fontSize || "16px",
);
const isOverflowing = computed(() => {
if (!container.value) {
return false;
}
return container.value.scrollHeight > remToPx(18);
});
const formattedCharacterCount =
characterCount > 0
? new Intl.NumberFormat(getLocale()).format(characterCount)
: undefined;
</script>

View file

@ -0,0 +1,12 @@
<template>
<div :class="[
'prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline hover:prose-a:underline',
$style.content,
]">
<slot />
</div>
</template>
<style module>
@import "~/styles/content.css";
</style>

View file

@ -0,0 +1,17 @@
<template>
<div class="flex flex-row gap-2 flex-wrap">
<Reaction v-for="reaction in reactions" :key="reaction.name" :reaction="reaction" :emoji="emojis.find(e => `:${e.shortcode}:` === reaction.name)" :status-id="statusId" />
</div>
</template>
<script lang="ts" setup>
import type { CustomEmoji, NoteReaction } from "@versia/client/schemas";
import type { z } from "zod";
import Reaction from "./reaction.vue";
const { statusId, reactions, emojis } = defineProps<{
statusId: string;
reactions: z.infer<typeof NoteReaction>[];
emojis: z.infer<typeof CustomEmoji>[];
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<div class="sticky top-2 z-10 flex items-center justify-center p-2">
<Badge variant="secondary">
{{ categoryName }}
</Badge>
</div>
</template>
<script lang="ts" setup>
import { Badge } from "~/components/ui/badge";
const { categoryName } = defineProps<{
categoryName: string;
}>();
</script>

View file

@ -0,0 +1,21 @@
<template>
<div class="p-2 text-sm font-semibold border-0 rounded-none text-center flex flex-row items-center gap-2 truncate">
<img v-if="(emoji as InferredEmoji)?.url" :src="(emoji as InferredEmoji)?.url"
:alt="(emoji as InferredEmoji)?.shortcode" class="h-8 align-middle inline not-prose" />
<span v-else-if="(emoji as UnicodeEmoji)?.unicode" class="text-2xl align-middle inline not-prose">
{{ (emoji as UnicodeEmoji)?.unicode }}
</span>
{{ (emoji as InferredEmoji)?.shortcode || (emoji as UnicodeEmoji)?.shortcode }}
</div>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import type { z } from "zod";
import type { UnicodeEmoji } from "./emoji.ts";
type InferredEmoji = z.infer<typeof CustomEmoji>;
const { emoji } = defineProps<{
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji | null;
}>();
</script>

View file

@ -0,0 +1,140 @@
/**
* Adapted from Cinny's code
* @see https://github.com/cinnyapp/cinny/blob/e6f4eeca8edc85ab64179e545b4e2e8c15763633/src/app/plugins/emoji.ts
*/
import type { CompactEmoji } from "emojibase";
import emojisData from "emojibase-data/en/compact.json";
import joypixels from "emojibase-data/en/shortcodes/joypixels.json";
export type UnicodeEmoji = CompactEmoji & {
shortcode: string;
};
export enum EmojiGroupId {
People = "People",
Nature = "Nature",
Food = "Food",
Activity = "Activity",
Travel = "Travel",
Object = "Object",
Symbol = "Symbol",
Flag = "Flag",
}
export type UnicodeEmojiGroup = {
id: EmojiGroupId;
order: number;
emojis: UnicodeEmoji[];
};
export const getShortcodesFor = (
hexcode: string,
): string[] | string | undefined => joypixels[hexcode];
export const getShortcodeFor = (hexcode: string): string | undefined => {
const shortcode = joypixels[hexcode];
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
};
export const emojiGroups: UnicodeEmojiGroup[] = [
{
id: EmojiGroupId.People,
order: 0,
emojis: [],
},
{
id: EmojiGroupId.Nature,
order: 1,
emojis: [],
},
{
id: EmojiGroupId.Food,
order: 2,
emojis: [],
},
{
id: EmojiGroupId.Activity,
order: 3,
emojis: [],
},
{
id: EmojiGroupId.Travel,
order: 4,
emojis: [],
},
{
id: EmojiGroupId.Object,
order: 5,
emojis: [],
},
{
id: EmojiGroupId.Symbol,
order: 6,
emojis: [],
},
{
id: EmojiGroupId.Flag,
order: 7,
emojis: [],
},
];
export const emojis: UnicodeEmoji[] = [];
function addEmojiToGroup(groupIndex: number, emoji: UnicodeEmoji) {
emojiGroups[groupIndex]?.emojis.push(emoji);
}
function getGroupIndex(emoji: UnicodeEmoji): number | undefined {
switch (emoji.group) {
case 0:
case 1:
return 0; // People
case 3:
return 1; // Nature
case 4:
return 2; // Food
case 6:
return 3; // Activity
case 5:
return 4; // Travel
case 7:
return 5; // Object
case 8:
case undefined:
return 6; // Symbol
case 9:
return 7; // Flag
default:
return undefined; // Unknown group
}
}
for (const emoji of emojisData) {
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) {
continue;
}
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) {
continue;
}
const em: UnicodeEmoji = {
...emoji,
shortcode: Array.isArray(myShortCodes)
? (myShortCodes[0] as string)
: myShortCodes,
shortcodes: Array.isArray(myShortCodes)
? myShortCodes
: emoji.shortcodes,
};
const groupIndex = getGroupIndex(em);
if (groupIndex !== undefined) {
addEmojiToGroup(groupIndex, em);
emojis.push(em);
}
}

View file

@ -0,0 +1,27 @@
<template>
<Button @focus="() => emit('select', emoji)" @mouseenter="() => emit('select', emoji)" @click="() => emit('pick', emoji)" size="icon" variant="ghost"
class="size-12">
<img v-if="(emoji as InferredEmoji).url" :src="(emoji as InferredEmoji).url"
:alt="(emoji as InferredEmoji).shortcode" class="h-8 align-middle inline not-prose" />
<span v-else-if="(emoji as UnicodeEmoji).unicode" class="text-2xl align-middle inline not-prose">
{{ (emoji as UnicodeEmoji).unicode }}
</span>
</Button>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import type { z } from "zod";
import { Button } from "~/components/ui/button";
import type { UnicodeEmoji } from "./emoji";
const { emoji } = defineProps<{
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji;
}>();
type InferredEmoji = z.infer<typeof CustomEmoji>;
const emit = defineEmits<{
select: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
}>();
</script>

View file

@ -0,0 +1,138 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent class="p-0 w-fit">
<div class="grid-cols-[minmax(0,1fr)_auto] gap-0 grid divide-x *:h-112 *:overflow-y-auto"
orientation="vertical">
<div class="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-0" ref="emojiContainer">
<div class="p-2">
<Input placeholder="Search" v-model="filter" />
</div>
<VList :data="virtualizedItems" #default="{ item }" class="relative" :style="{
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
}">
<CategoryHeader :key="item.headerId" v-if="item.type === 'header'" :category-name="item.name" />
<div v-else-if="item.type === 'emoji-row'" :key="item.rowId" class="flex gap-1 p-2">
<Emoji v-for="emoji in item.emojis" :key="getEmojiKey(emoji)" :emoji="emoji"
@select="(e) => selectedEmoji = e" @pick="e => {
emit('pick', e); open = false;
}" />
</div>
</VList>
<EmojiDisplay :emoji="selectedEmoji" :style="{
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
}" />
</div>
<Sidebar :categories="categories" @select="scrollToCategory" />
</div>
</PopoverContent>
</Popover>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import { VList } from "virtua/vue";
import type { z } from "zod";
import { Input } from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import CategoryHeader from "./category-header.vue";
import EmojiDisplay from "./display.vue";
import { type EmojiGroupId, emojiGroups, type UnicodeEmoji } from "./emoji.ts";
import Emoji from "./emoji.vue";
import Sidebar from "./sidebar.vue";
import { EMOJI_PER_ROW, getVirtualizedItems } from "./virtual.ts";
const emit = defineEmits<{
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
}>();
const open = ref(false);
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
null,
);
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
const filter = ref("");
const customEmojis = computed(() => identity.value?.emojis ?? []);
const customEmojiCategories = computed(() => {
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
for (const emoji of customEmojis.value) {
const categoryName = emoji.category || "Uncategorized";
if (!categories[categoryName]) {
categories[categoryName] = [];
}
categories[categoryName]?.push(emoji);
}
return categories;
});
const categories = computed(() => {
const customCategories = Object.entries(customEmojiCategories.value).map(
([name, emojis]) => ({
name,
src: (emojis[0]?.url as string) || "",
}),
);
const groupCategories = emojiGroups.map((group) => ({
name: group.id,
groupId: group.id,
}));
return [...customCategories, ...groupCategories];
});
const virtualizedItems = computed(() =>
getVirtualizedItems(customEmojiCategories.value, filter.value),
);
const getEmojiKey = (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
if ("url" in emoji) {
return `custom-${emoji.shortcode}`;
}
return `unicode-${emoji.shortcode}`;
};
const scrollToCategory = (category: {
name: string;
groupId?: EmojiGroupId;
src?: string;
}) => {
const categoryId = category.groupId || `custom-${category.name}`;
const headerIndex = virtualizedItems.value.findIndex(
(item) => item.type === "header" && item.categoryId === categoryId,
);
const child = emojiContainer.value?.children[1];
if (headerIndex !== -1 && child) {
// Estimate scroll position based on item heights
// Headers are approximately 38px, emoji rows are approximately 64px
let scrollTop = 0;
for (let i = 0; i < headerIndex; i++) {
const item = virtualizedItems.value[i];
if (item?.type === "header") {
scrollTop += 38;
} else if (item?.type === "emoji-row") {
scrollTop += 64;
}
}
child.scrollTo({
top: scrollTop,
behavior: "smooth",
});
}
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="grid gap-1 bg-transparent p-2">
<Button v-for="category in categories" :key="category.name" size="icon" variant="ghost" @click="() => emit('select', category)">
<component v-if="category.groupId" :is="emojiGroupIconMap[category.groupId]" class="size-6 text-primary" />
<img v-else-if="category.src" :src="category.src" class="size-6 align-middle inline not-prose" role="presentation" />
</Button>
</div>
</template>
<script lang="ts" setup>
import {
Box,
CarFront,
Flag,
Leaf,
Percent,
Pizza,
Smile,
Volleyball,
} from "lucide-vue-next";
import type { FunctionalComponent } from "vue";
import { Button } from "~/components/ui/button";
import { EmojiGroupId } from "./emoji";
const { categories } = defineProps<{
categories: {
name: string;
groupId?: EmojiGroupId;
src?: string;
}[];
}>();
const emit = defineEmits<{
select: [category: { name: string; groupId?: EmojiGroupId; src?: string }];
}>();
const emojiGroupIconMap: Record<EmojiGroupId, FunctionalComponent> = {
[EmojiGroupId.People]: Smile,
[EmojiGroupId.Nature]: Leaf,
[EmojiGroupId.Food]: Pizza,
[EmojiGroupId.Activity]: Volleyball,
[EmojiGroupId.Travel]: CarFront,
[EmojiGroupId.Object]: Box,
[EmojiGroupId.Symbol]: Percent,
[EmojiGroupId.Flag]: Flag,
};
</script>

View file

@ -0,0 +1,117 @@
import type { CustomEmoji } from "@versia/client/schemas";
import { go } from "fuzzysort";
import { nanoid } from "nanoid";
import type { z } from "zod";
import { emojiGroups, type UnicodeEmoji } from "./emoji";
export const EMOJI_PER_ROW = 7;
export type VirtualizedItem =
| { headerId: string; type: "header"; name: string; categoryId: string }
| {
rowId: string;
type: "emoji-row";
emojis: (z.infer<typeof CustomEmoji> | UnicodeEmoji)[];
};
export const getVirtualizedItems = (
customCategories: Record<string, z.infer<typeof CustomEmoji>[]>,
searchQuery?: string,
): VirtualizedItem[] => {
const items: VirtualizedItem[] = [];
// Add custom emoji categories first
for (const [categoryName, categoryEmojis] of Object.entries(
customCategories,
)) {
// Add category header
items.push({
headerId: nanoid(),
type: "header",
name: categoryName,
categoryId: `custom-${categoryName}`,
});
// Add emoji rows for this category
for (let i = 0; i < categoryEmojis.length; i += EMOJI_PER_ROW) {
items.push({
rowId: nanoid(),
type: "emoji-row",
emojis: categoryEmojis.slice(i, i + EMOJI_PER_ROW),
});
}
}
// Add unicode emoji groups
for (const group of emojiGroups) {
if (group.emojis.length === 0) {
continue;
}
// Add group header
items.push({
headerId: nanoid(),
type: "header",
name: group.id,
categoryId: group.id,
});
// Add emoji rows for this group
for (let i = 0; i < group.emojis.length; i += EMOJI_PER_ROW) {
items.push({
rowId: nanoid(),
type: "emoji-row",
emojis: group.emojis.slice(i, i + EMOJI_PER_ROW),
});
}
}
// If search query is provided, add extra category for search results
// with emojis that contain the search query in their shortcode
// ordered with fuzzysort
if (searchQuery) {
const customEmojiMatches = Object.values(customCategories)
.flat()
.filter((emoji) =>
emoji.shortcode
.toLowerCase()
.includes(searchQuery.toLowerCase()),
);
const unicodeEmojiMatches = emojiGroups
.flatMap((group) => group.emojis)
.filter((emoji) =>
emoji.shortcode
.toLowerCase()
.includes(searchQuery.toLowerCase()),
);
const results = go(
searchQuery,
[...customEmojiMatches, ...unicodeEmojiMatches],
{
key: "shortcode",
limit: 20,
},
);
items.splice(0, 0, {
headerId: nanoid(),
type: "header",
name: "Search Results",
categoryId: "search-results",
});
for (let i = 0; i < results.length; i += EMOJI_PER_ROW) {
const emojis = results
.slice(i, i + EMOJI_PER_ROW)
.map((result) => result.obj);
items.splice(1 + i / EMOJI_PER_ROW, 0, {
rowId: nanoid(),
type: "emoji-row",
emojis,
});
}
}
return items;
};

View file

@ -0,0 +1,101 @@
<template>
<HoverCard @update:open="(open) => open && accounts === null && refreshReactions()">
<HoverCardTrigger as-child>
<Button @click="reaction.me ? !reaction.remote && unreact() : !reaction.remote && react()" :variant="reaction.me ? 'secondary' : reaction.remote ? 'ghost' : 'outline'" size="sm" class="gap-2">
<img v-if="emoji" :src="emoji.url" :alt="emoji.shortcode"
class="h-[1lh] align-middle inline not-prose" />
<span v-else>
{{ reaction.name }}
</span>
{{ formatNumber(reaction.count) }}
</Button>
</HoverCardTrigger>
<HoverCardContent class="p-3">
<Spinner v-if="accounts === null" class="border-0" />
<ul v-else class="flex flex-col gap-4">
<li
v-for="account in accounts">
<NuxtLink :to="`/@${account.acct}`" class="flex items-center gap-2">
<Avatar class="size-6" :key="account.id" :src="account.avatar"
:name="account.display_name || account.username" />
<span class="text-sm font-semibold line-clamp-1">
{{ account.display_name || account.username }}
</span>
</NuxtLink>
</li>
</ul>
</HoverCardContent>
</HoverCard>
</template>
<script lang="ts" setup>
import type {
Account,
CustomEmoji,
NoteReaction,
} from "@versia/client/schemas";
import { toast } from "vue-sonner";
import type { z } from "zod";
import Spinner from "~/components/graphics/spinner.vue";
import Avatar from "~/components/profiles/avatar.vue";
import { Button } from "~/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "~/components/ui/hover-card";
import * as m from "~~/paraglide/messages.js";
import { getLocale } from "~~/paraglide/runtime.js";
const { reaction, emoji, statusId } = defineProps<{
statusId: string;
reaction: z.infer<typeof NoteReaction>;
emoji?: z.infer<typeof CustomEmoji>;
}>();
const formatNumber = (number: number) =>
new Intl.NumberFormat(getLocale(), {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(number);
const accounts = ref<z.infer<typeof Account>[] | null>(null);
const refreshReactions = async () => {
const { data } = await client.value.getStatusReactions(statusId);
const accountIds =
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
[];
const { data: accountsData } = await client.value.getAccounts(accountIds);
accounts.value = accountsData;
};
const react = async () => {
const id = toast.loading(m.gray_stale_antelope_roam());
const { data } = await client.value.createEmojiReaction(
statusId,
reaction.name,
);
toast.dismiss(id);
toast.success(m.main_least_turtle_fall());
useEvent("note:edit", data);
};
const unreact = async () => {
const id = toast.loading(m.many_weary_bat_intend());
const { data } = await client.value.deleteEmojiReaction(
statusId,
reaction.name,
);
toast.dismiss(id);
toast.success(m.aware_even_oryx_race());
useEvent("note:edit", data);
};
</script>

View file

@ -0,0 +1,28 @@
<template>
<NuxtLink :href="urlAsPath">
<Card class="flex-row px-2 py-1 items-center gap-2 hover:bg-muted duration-100 text-sm">
<Repeat class="size-4 text-primary" />
<Avatar class="size-6 border" :src="avatar" :name="displayName" />
<span class="font-semibold" v-render-emojis="emojis">{{ displayName }}</span>
{{ m.large_vivid_horse_catch() }}
</Card>
</NuxtLink>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import { Repeat } from "lucide-vue-next";
import type { z } from "zod";
import * as m from "~~/paraglide/messages.js";
import Avatar from "../profiles/avatar.vue";
import { Card } from "../ui/card";
const { url } = defineProps<{
avatar: string;
displayName: string;
emojis: z.infer<typeof CustomEmoji>[];
url: string;
}>();
const urlAsPath = new URL(url).pathname;
</script>

View file

@ -0,0 +1,29 @@
<template>
<div>
<Note
v-if="parent"
:note="parent"
:hide-actions="true"
:content-under-username="true"
:bottom-avatar-bar="true"
class="border-b-0 rounded-b-none"
/>
<Note
:note="note"
:class="parent && 'border-t-0 rounded-t-none'"
:top-avatar-bar="!!parent"
/>
</div>
</template>
<script lang="ts" setup>
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import Note from "./note.vue";
const { note } = defineProps<{
note: z.infer<typeof Status>;
}>();
const parent = useNote(client, note.in_reply_to_id);
</script>