refactor: ♻️ Simplify Note code with a provide/inject pattern
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 1s
Deploy to GitHub Pages / build (push) Failing after 0s
Deploy to GitHub Pages / deploy (push) Has been skipped
Docker / build (push) Failing after 0s
Mirror to Codeberg / Mirror (push) Failing after 0s

This commit is contained in:
Jesse Wierzbinski 2026-01-09 23:10:45 +01:00
parent b23ed66401
commit f5918cc7f9
No known key found for this signature in database
12 changed files with 140 additions and 199 deletions

View file

@ -6,7 +6,7 @@
:title="m.drab_tense_turtle_comfort()"
:disabled="!authStore.isSignedIn"
>
{{ numberFormat(replyCount) }}
{{ numberFormat(note.replies_count) }}
</ActionButton>
<ActionButton
:icon="Heart"
@ -15,7 +15,7 @@
:disabled="!authStore.isSignedIn"
:class="liked && '*:fill-red-600 *:text-red-600'"
>
{{ numberFormat(likeCount) }}
{{ numberFormat(note.favourites_count) }}
</ActionButton>
<ActionButton
:icon="Repeat"
@ -24,7 +24,7 @@
:disabled="!authStore.isSignedIn"
:class="reblogged && '*:text-green-600'"
>
{{ numberFormat(reblogCount) }}
{{ numberFormat(note.reblogs_count) }}
</ActionButton>
<ActionButton
:icon="Quote"
@ -51,22 +51,12 @@ import * as m from "~~/paraglide/messages.js";
import { getLocale } from "~~/paraglide/runtime";
import { confirmModalService } from "../modals/composable";
import ActionButton from "./action-button.vue";
import { key } from "./provider";
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;
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note } = inject(key)!;
const emit = defineEmits<{
edit: [];
@ -78,6 +68,9 @@ const emit = defineEmits<{
const { play } = useAudio();
const authStore = useAuthStore();
const liked = note.favourited ?? false;
const reblogged = note.reblogged ?? false;
const like = async () => {
if (preferences.confirm_actions.value.includes("like")) {
const confirmation = await confirmModalService.confirm({
@ -94,7 +87,7 @@ const like = async () => {
play("like");
const id = toast.loading(m.slimy_candid_tiger_read());
const { data } = await authStore.client.favouriteStatus(noteId);
const { data } = await authStore.client.favouriteStatus(note.id);
toast.dismiss(id);
toast.success(m.mealy_slow_buzzard_commend());
useEvent("note:edit", data);
@ -115,7 +108,7 @@ const unlike = async () => {
}
const id = toast.loading(m.busy_active_leopard_strive());
const { data } = await authStore.client.unfavouriteStatus(noteId);
const { data } = await authStore.client.unfavouriteStatus(note.id);
toast.dismiss(id);
toast.success(m.fresh_direct_bear_affirm());
useEvent("note:edit", data);
@ -136,7 +129,7 @@ const reblog = async () => {
}
const id = toast.loading(m.late_sunny_cobra_scold());
const { data } = await authStore.client.reblogStatus(noteId);
const { data } = await authStore.client.reblogStatus(note.id);
toast.dismiss(id);
toast.success(m.weird_moving_hawk_lift());
useEvent(
@ -160,7 +153,7 @@ const unreblog = async () => {
}
const id = toast.loading(m.white_sharp_gorilla_embrace());
const { data } = await authStore.client.unreblogStatus(noteId);
const { data } = await authStore.client.unreblogStatus(note.id);
toast.dismiss(id);
toast.success(m.royal_polite_moose_catch());
useEvent("note:edit", data);
@ -172,7 +165,7 @@ const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
? (emoji as UnicodeEmoji).unicode
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
const { data } = await authStore.client.createEmojiReaction(noteId, text);
const { data } = await authStore.client.createEmojiReaction(note.id, text);
toast.dismiss(id);
toast.success(m.main_least_turtle_fall());

View file

@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-1">
<p class="text-sm leading-6 wrap-anywhere">
{{ contentWarning || m.sour_seemly_bird_hike() }}
{{ note.spoiler_text || m.sour_seemly_bird_hike() }}
</p>
<Button
@click="hidden = !hidden"
@ -11,9 +11,7 @@
>
{{ hidden ? m.bald_direct_turtle_win() :
m.known_flaky_cockroach_dash() }}
{{ characterCount > 0 ? ` (${characterCount} characters` : "" }}
{{ attachmentCount > 0 ? `${characterCount > 0 ? " · " : " ("}${attachmentCount} file(s)` : "" }}
{{ (characterCount > 0 || attachmentCount > 0) ? ")" : "" }}
{{ constructText() }}
</Button>
</div>
</template>
@ -21,14 +19,33 @@
<script lang="ts" setup>
import * as m from "~~/paraglide/messages.js";
import { Button } from "../ui/button";
import { key } from "./provider";
const { contentWarning, characterCount, attachmentCount } = defineProps<{
contentWarning?: string;
characterCount: number;
attachmentCount: number;
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note } = inject(key)!;
const attachmentCount = note.media_attachments.length;
const characterCount = note.text?.length || 0;
const hidden = defineModel<boolean>({
default: true,
});
const constructText = () => {
const parts: string[] = [];
if (characterCount > 0) {
parts.push(
`${characterCount} character${characterCount === 1 ? "" : "s"}`,
);
}
if (attachmentCount > 0) {
parts.push(
`${attachmentCount} file${attachmentCount === 1 ? "" : "s"}`,
);
}
return parts.length > 0 ? ` (${parts.join(" · ")})` : "";
};
</script>

View file

@ -1,51 +1,40 @@
<template>
<ContentWarning
v-if="(sensitive || contentWarning) && preferences.show_content_warning"
:content-warning="contentWarning"
:character-count="characterCount ?? 0"
:attachment-count="attachments.length"
v-if="(note.sensitive || note.spoiler_text) && preferences.show_content_warning"
v-model="hidden"
/>
<OverflowGuard
v-if="content"
v-if="note.content"
:character-count="characterCount"
:class="(hidden && preferences.show_content_warning) && 'hidden'"
>
<Prose v-html="content" v-render-emojis="emojis"></Prose>
<Prose v-html="note.content" v-render-emojis="note.emojis"></Prose>
</OverflowGuard>
<Attachments
v-if="attachments.length > 0"
:attachments="attachments"
v-if="note.media_attachments.length > 0"
:attachments="note.media_attachments"
:class="(hidden && preferences.show_content_warning) && 'hidden'"
/>
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
<Note :note="quote" :hide-actions="true" :small-layout="true" />
<div v-if="note.quote" class="mt-4 rounded border overflow-hidden">
<Note :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";
import { key } from "./provider";
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;
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note } = inject(key)!;
const hidden = ref(sensitive || !!contentWarning);
const hidden = ref(note.sensitive || !!note.spoiler_text);
const characterCount = plainContent?.length;
const characterCount = note.text?.length;
</script>

View file

@ -2,14 +2,17 @@
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<NuxtLink :href="urlAsPath">
<Avatar :src="author.avatar" :name="author.display_name" />
<Avatar
:src="note.account.avatar"
:name="note.account.display_name"
/>
</NuxtLink>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-1">
<span
class="text-sm font-semibold"
v-render-emojis="author.emojis"
>{{ author.display_name }}</span
v-render-emojis="note.account.emojis"
>{{ note.account.display_name }}</span
>
</div>
<div
@ -23,16 +26,7 @@
</div>
</div>
</div>
<Menu
:api-note-string="apiNoteString"
:url="noteUrl"
:remote-url="remoteUrl"
:is-remote="isRemote"
:author-id="author.id"
@edit="emit('edit')"
:note-id="noteId"
@delete="emit('delete')"
>
<Menu @edit="emit('edit')" @delete="emit('delete')">
<Button variant="ghost" size="icon">
<Ellipsis />
</Button>
@ -41,36 +35,25 @@
</template>
<script lang="ts" setup>
import type { Account, Status } from "@versia/client/schemas";
import type {
UseTimeAgoMessages,
UseTimeAgoUnitNamesDefault,
} from "@vueuse/core";
import { AtSign, Ellipsis, Globe, Lock, LockOpen } from "lucide-vue-next";
import type { z } from "zod";
import { getLocale } from "~~/paraglide/runtime";
import { Ellipsis } from "lucide-vue-next";
import Avatar from "../profiles/avatar.vue";
import { Button } from "../ui/button";
import Menu from "./menu.vue";
import { key } from "./provider";
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
cornerAvatar?: string;
visibility: z.infer<typeof Status.shape.visibility>;
noteUrl: string;
createdAt: Date;
author: z.infer<typeof Account>;
authorUrl: string;
remoteUrl?: string;
apiNoteString: string;
isRemote: boolean;
noteId: string;
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note } = inject(key)!;
const [username, instance] = author.acct.split("@");
const [username, instance] = note.account.acct.split("@");
const digitRegex = /\d/;
const urlAsPath = new URL(authorUrl).pathname;
const noteUrlAsPath = new URL(noteUrl).pathname;
const timeAgo = useTimeAgo(createdAt, {
const accountUrl = wrapUrl(`/@${note.account.acct}`);
const urlAsPath = new URL(accountUrl).pathname;
const timeAgo = useTimeAgo(note.created_at, {
messages: {
justNow: "now",
past: (n) => (n.match(digitRegex) ? `${n}` : n),
@ -85,33 +68,9 @@ const timeAgo = useTimeAgo(createdAt, {
invalid: "",
} as UseTimeAgoMessages<UseTimeAgoUnitNamesDefault>,
});
const fullTime = new Intl.DateTimeFormat(getLocale(), {
dateStyle: "medium",
timeStyle: "short",
}).format(createdAt);
const popupOpen = ref(false);
const emit = defineEmits<{
edit: [];
delete: [];
}>();
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

@ -21,15 +21,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { confirmModalService } from "~/components/modals/composable.ts";
import * as m from "~~/paraglide/messages.js";
import { key } from "./provider";
const { authorId, noteId } = defineProps<{
apiNoteString: string;
isRemote: boolean;
url: string;
remoteUrl?: string;
authorId: string;
noteId: string;
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note, isRemote } = inject(key)!;
const emit = defineEmits<{
edit: [];
@ -38,7 +33,9 @@ const emit = defineEmits<{
const { copy } = useClipboard();
const authStore = useAuthStore();
const authorIsMe = authStore.isSignedIn && authorId === authStore.account?.id;
const url = wrapUrl(`/@${note.account.acct}/${note.id}`);
const authorIsMe =
authStore.isSignedIn && note.account.id === authStore.account?.id;
const copyText = (text: string) => {
copy(text);
@ -68,7 +65,7 @@ const _delete = async () => {
}
const id = toast.loading(m.new_funny_fox_boil());
await authStore.client.deleteStatus(noteId);
await authStore.client.deleteStatus(note.id);
toast.dismiss(id);
toast.success(m.green_tasty_bumblebee_beam());
@ -91,11 +88,14 @@ const _delete = async () => {
<Pencil />
{{ m.front_lime_grizzly_persist() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
<DropdownMenuItem
as="button"
@click="copyText(JSON.stringify(note, null, 4))"
>
<Code />
{{ m.yummy_moving_scallop_sail() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(noteId)">
<DropdownMenuItem as="button" @click="copyText(note.id)">
<Hash />
{{ m.sunny_zany_jellyfish_pop() }}
</DropdownMenuItem>
@ -108,8 +108,8 @@ const _delete = async () => {
</DropdownMenuItem>
<DropdownMenuItem
as="button"
v-if="isRemote && remoteUrl"
@click="copyText(remoteUrl)"
v-if="isRemote && note.url"
@click="copyText(note.url)"
>
<Link />
{{ m.solid_witty_zebra_walk() }}
@ -119,7 +119,7 @@ const _delete = async () => {
v-if="isRemote"
target="_blank"
rel="noopener noreferrer"
:href="remoteUrl"
:href="note.url"
>
<ExternalLink />
{{ m.active_trite_lark_inspire() }}
@ -142,7 +142,10 @@ const _delete = async () => {
<Flag />
{{ m.great_few_jaguar_rise() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="blockUser(authorId)">
<DropdownMenuItem
as="button"
@click="blockUser(note.account.id)"
>
<Ban />
{{ m.misty_soft_sparrow_vent() }}
</DropdownMenuItem>

View file

@ -8,63 +8,25 @@
class="absolute left-0 top-0 bottom-0 w-2 bg-border rounded-tl-md"
/>
<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"
/>
<ReblogHeader v-if="note.reblog" />
<Header
:author="noteToUse.account"
:author-url="accountUrl"
:corner-avatar="note.reblog ? note.account.avatar : undefined"
:note-url="url"
:is-remote="isRemote"
:remote-url="noteToUse.url ?? undefined"
:api-note-string="JSON.stringify(noteToUse, null, 4)"
:visibility="noteToUse.visibility"
:created-at="new Date(noteToUse.created_at)"
@edit="useEvent('composer:edit', noteToUse)"
@delete="useEvent('note:delete', noteToUse)"
:note-id="noteToUse.id"
class="z-1"
/>
</CardHeader>
<CardContent class="space-y-2">
<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"
/>
<Content />
<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>
@ -77,6 +39,7 @@ import { Card, CardContent, CardFooter, CardHeader } from "../ui/card";
import Actions from "./actions.vue";
import Content from "./content.vue";
import Header from "./header.vue";
import { key } from "./provider";
import Reactions from "./reactions/index.vue";
import ReblogHeader from "./reblog-header.vue";
@ -99,8 +62,9 @@ const noteToUse = computed(() =>
: (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("@");
provide(key, {
note: noteToUse.value,
rebloggerNote: note.reblog ? (note as z.infer<typeof Status>) : undefined,
isRemote: noteToUse.value.account.acct.includes("@"),
});
</script>

View file

@ -1,7 +1,7 @@
<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',
'prose prose-sm block relative dark:prose-invert duration-200 max-w-full! wrap-break-word prose-a:no-underline hover:prose-a:underline',
$style.content,
]"
>

View file

@ -0,0 +1,11 @@
import type { Status } from "@versia/client/schemas";
import type { InjectionKey } from "vue";
import type { z } from "zod";
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export const key = Symbol() as InjectionKey<{
note: PartialBy<z.infer<typeof Status>, "reblog" | "quote">;
isRemote: boolean;
rebloggerNote?: z.infer<typeof Status>;
}>;

View file

@ -1,23 +1,18 @@
<template>
<div class="flex flex-row gap-1 flex-wrap">
<Reaction
v-for="reaction in reactions"
v-for="reaction in note.reactions"
:key="reaction.name"
:reaction="reaction"
:emoji="emojis.find(e => `:${e.shortcode}:` === reaction.name)"
:status-id="statusId"
:emoji="note.emojis.find(e => `:${e.shortcode}:` === reaction.name)"
/>
</div>
</template>
<script lang="ts" setup>
import type { CustomEmoji, NoteReaction } from "@versia/client/schemas";
import type { z } from "zod";
import { key } from "../provider";
import Reaction from "./reaction.vue";
const { statusId, reactions, emojis } = defineProps<{
statusId: string;
reactions: z.infer<typeof NoteReaction>[];
emojis: z.infer<typeof CustomEmoji>[];
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note } = inject(key)!;
</script>

View file

@ -61,14 +61,16 @@ import {
} from "~/components/ui/hover-card";
import * as m from "~~/paraglide/messages.js";
import { getLocale } from "~~/paraglide/runtime.js";
import { key } from "../provider";
const { reaction, emoji, statusId } = defineProps<{
statusId: string;
const { reaction, emoji } = defineProps<{
reaction: z.infer<typeof NoteReaction>;
emoji?: z.infer<typeof CustomEmoji>;
}>();
const authStore = useAuthStore();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { note } = inject(key)!;
const formatNumber = (number: number) =>
new Intl.NumberFormat(getLocale(), {
@ -80,7 +82,7 @@ const formatNumber = (number: number) =>
const accounts = ref<z.infer<typeof Account>[] | null>(null);
const refreshReactions = async () => {
const { data } = await authStore.client.getStatusReactions(statusId);
const { data } = await authStore.client.getStatusReactions(note.id);
const accountIds =
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
[];
@ -95,7 +97,7 @@ const react = async () => {
const id = toast.loading(m.gray_stale_antelope_roam());
const { data } = await authStore.client.createEmojiReaction(
statusId,
note.id,
reaction.name,
);
@ -108,7 +110,7 @@ const unreact = async () => {
const id = toast.loading(m.many_weary_bat_intend());
const { data } = await authStore.client.deleteEmojiReaction(
statusId,
note.id,
reaction.name,
);

View file

@ -4,9 +4,15 @@
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
<Avatar
class="size-6 border"
:src="rebloggerNote.account.avatar"
:name="rebloggerNote.account.display_name"
/>
<span
class="font-semibold"
v-render-emojis="rebloggerNote.account.emojis"
>{{ rebloggerNote.account.display_name }}</span
>
{{ m.large_vivid_horse_catch() }}
</Card>
@ -14,19 +20,21 @@
</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";
import { key } from "./provider";
const { url } = defineProps<{
avatar: string;
displayName: string;
emojis: z.infer<typeof CustomEmoji>[];
url: string;
}>();
// biome-ignore lint/style/noNonNullAssertion: We want an error if not provided
const { rebloggerNote } = inject(key)!;
const urlAsPath = new URL(url).pathname;
if (!rebloggerNote) {
throw new Error(
"ReblogHeader must be used with a rebloggerNote in context",
);
}
const reblogAccountUrl = wrapUrl(`/@${rebloggerNote.account.acct}`);
const urlAsPath = new URL(reblogAccountUrl).pathname;
</script>

View file

@ -60,7 +60,7 @@ const props = defineProps<{
const emit = defineEmits<(e: "update") => void>();
const loadMoreTrigger = ref<HTMLElement | null>(null);
const loadMoreTrigger = useTemplateRef("loadMoreTrigger");
useIntersectionObserver(loadMoreTrigger, ([observer]) => {
if (observer?.isIntersecting && !props.isLoading && !props.hasReachedEnd) {