feat: Implement custom emojis

This commit is contained in:
Jesse Wierzbinski 2024-12-02 22:21:04 +01:00
parent bb5de77bb1
commit 348b1ba2b0
No known key found for this signature in database
12 changed files with 77 additions and 20 deletions

View file

@ -3,7 +3,7 @@
maxHeight: collapsed ? '18rem' : `${container?.scrollHeight}px` maxHeight: collapsed ? '18rem' : `${container?.scrollHeight}px`
}"> }">
<div :class="['prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" <div :class="['prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]"
v-html="content"> v-html="content" v-render-emojis="emojis">
</div> </div>
<div v-if="isOverflowing && collapsed" <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"> class="absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/5 to-transparent rounded-b">
@ -24,7 +24,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Attachment, Status } from "@versia/client/types"; import type { Attachment, Emoji, Status } from "@versia/client/types";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import Attachments from "./attachments.vue"; import Attachments from "./attachments.vue";
import Note from "./note.vue"; import Note from "./note.vue";
@ -33,6 +33,7 @@ const { content, plainContent } = defineProps<{
plainContent?: string; plainContent?: string;
content: string; content: string;
quote?: NonNullable<Status["quote"]>; quote?: NonNullable<Status["quote"]>;
emojis: Emoji[];
attachments: Attachment[]; attachments: Attachment[];
}>(); }>();
const container = ref<HTMLDivElement | null>(null); const container = ref<HTMLDivElement | null>(null);

View file

@ -11,7 +11,7 @@
</Avatar> </Avatar>
</NuxtLink> </NuxtLink>
<div :class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'text-sm')"> <div :class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'text-sm')">
<span class="truncate font-semibold">{{ <span class="truncate font-semibold" v-render-emojis="emojis">{{
displayName displayName
}}</span> }}</span>
<span class="truncate text-sm tracking-tight"> <span class="truncate text-sm tracking-tight">
@ -36,7 +36,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { StatusVisibility } from "@versia/client/types"; import type { Emoji, StatusVisibility } from "@versia/client/types";
import type { import type {
UseTimeAgoMessages, UseTimeAgoMessages,
UseTimeAgoUnitNamesDefault, UseTimeAgoUnitNamesDefault,
@ -49,6 +49,7 @@ const { acct, createdAt, url } = defineProps<{
cornerAvatar?: string; cornerAvatar?: string;
acct: string; acct: string;
displayName: string; displayName: string;
emojis: Emoji[];
visibility: StatusVisibility; visibility: StatusVisibility;
url: string; url: string;
createdAt: Date; createdAt: Date;

View file

@ -2,14 +2,14 @@
<Card as="article" class="rounded-none border-0 duration-200 shadow- max-w-full"> <Card as="article" class="rounded-none border-0 duration-200 shadow- max-w-full">
<CardHeader class="pb-4" as="header"> <CardHeader class="pb-4" as="header">
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name" <ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
:url="reblogAccountUrl" /> :url="reblogAccountUrl" :emojis="note.account.emojis" />
<Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined" <Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined"
:acct="noteToUse.account.acct" :display-name="noteToUse.account.display_name" :acct="noteToUse.account.acct" :display-name="noteToUse.account.display_name"
:visibility="noteToUse.visibility" :url="accountUrl" :created-at="new Date(noteToUse.created_at)" :visibility="noteToUse.visibility" :url="accountUrl" :created-at="new Date(noteToUse.created_at)"
:small-layout="smallLayout" /> :small-layout="smallLayout" :emojis="noteToUse.account.emojis" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Content :content="noteToUse.content" :quote="note.quote ?? undefined" :attachments="noteToUse.media_attachments" :plain-content="noteToUse.plain_content ?? undefined"/> <Content :content="noteToUse.content" :quote="note.quote ?? undefined" :attachments="noteToUse.media_attachments" :plain-content="noteToUse.plain_content ?? undefined" :emojis="noteToUse.emojis" />
</CardContent> </CardContent>
<CardFooter v-if="!hideActions" class="p-4 pt-0"> <CardFooter v-if="!hideActions" class="p-4 pt-0">
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count" :url="url" <Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count" :url="url"

View file

@ -5,18 +5,20 @@
<AvatarImage :src="avatar" alt="" /> <AvatarImage :src="avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar> </Avatar>
<span class="font-semibold">{{ displayName }}</span> <span class="font-semibold" v-render-emojis="emojis">{{ displayName }}</span>
reblogged reblogged
</NuxtLink> </NuxtLink>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { Emoji } from "@versia/client/types";
import { Repeat } from "lucide-vue-next"; import { Repeat } from "lucide-vue-next";
const { url } = defineProps<{ const { url } = defineProps<{
avatar: string; avatar: string;
displayName: string; displayName: string;
emojis: Emoji[];
url: string; url: string;
}>(); }>();

View file

@ -7,7 +7,7 @@
</Avatar> </Avatar>
</NuxtLink> </NuxtLink>
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight text-sm"> <div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight text-sm">
<span class="truncate font-semibold">{{ <span class="truncate font-semibold" v-render-emojis="follower.emojis">{{
follower.display_name follower.display_name
}}</span> }}</span>
<span class="truncate tracking-tight"> <span class="truncate tracking-tight">

View file

@ -10,7 +10,7 @@
<AvatarImage :src="notification.account.avatar" alt="" /> <AvatarImage :src="notification.account.avatar" alt="" />
<AvatarFallback> AA </AvatarFallback> <AvatarFallback> AA </AvatarFallback>
</Avatar> </Avatar>
<span class="font-semibold">{{ <span class="font-semibold" v-render-emojis="notification.account.emojis">{{
notification.account.display_name notification.account.display_name
}}</span> }}</span>
<CollapsibleTrigger :as-child="true"> <CollapsibleTrigger :as-child="true">

View file

@ -1,11 +1,14 @@
<template> <template>
<div :class="['prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content"> <div :class="['prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content" v-render-emojis="emojis">
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
const { content } = defineProps<{ const { content } = defineProps<{
content: string; content: string;
emojis: Emoji[];
}>(); }>();
</script> </script>

View file

@ -1,16 +1,17 @@
<template> <template>
<div class="flex flex-col gap-y-4"> <div class="flex flex-col gap-y-4">
<div v-for="field in fields" :key="field.name" class="flex flex-col gap-1"> <div v-for="field in fields" :key="field.name" class="flex flex-col gap-1">
<h3 class="font-semibold text-sm">{{ field.name }}</h3> <h3 class="font-semibold text-sm" v-render-emojis="emojis">{{ field.name }}</h3>
<div v-html="field.value" class="prose prose-sm prose-zinc dark:prose-invert"></div> <div v-html="field.value" class="prose prose-sm prose-zinc dark:prose-invert" v-render-emojis="emojis"></div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Field } from "@versia/client/types"; import type { Emoji, Field } from "@versia/client/types";
defineProps<{ defineProps<{
fields: Field[]; fields: Field[];
emojis: Emoji[];
}>(); }>();
</script> </script>

View file

@ -17,7 +17,7 @@
</ProfileActions> </ProfileActions>
</div> </div>
<div class="flex flex-col -mt-1 gap-2 justify-center"> <div class="flex flex-col -mt-1 gap-2 justify-center">
<CardTitle class=""> <CardTitle class="" v-render-emojis="account.emojis">
{{ account.display_name }} {{ account.display_name }}
</CardTitle> </CardTitle>
<CopyableText :text="account.acct"> <CopyableText :text="account.acct">
@ -36,13 +36,13 @@
<ProfileBadge v-for="role in roles" :key="role.id" :name="role.name" :description="role.description" <ProfileBadge v-for="role in roles" :key="role.id" :name="role.name" :description="role.description"
:icon="role.icon" /> :icon="role.icon" />
</div> </div>
<ProfileContent :content="account.note" /> <ProfileContent :content="account.note" :emojis="account.emojis" />
</CardContent> </CardContent>
<CardFooter class="flex-col items-start gap-4"> <CardFooter class="flex-col items-start gap-4">
<ProfileStats :creation-date="new Date(account.created_at || 0)" :follower-count="account.followers_count" <ProfileStats :creation-date="new Date(account.created_at || 0)" :follower-count="account.followers_count"
:following-count="account.following_count" :note-count="account.statuses_count" /> :following-count="account.following_count" :note-count="account.statuses_count" />
<Separator v-if="account.fields.length > 0" /> <Separator v-if="account.fields.length > 0" />
<ProfileFields v-if="account.fields.length > 0" :fields="account.fields" /> <ProfileFields v-if="account.fields.length > 0" :fields="account.fields" :emojis="account.emojis" />
</CardFooter> </CardFooter>
</Card> </Card>
</template> </template>

View file

@ -199,7 +199,7 @@ const instance = useInstance();
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar> </Avatar>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ <span class="truncate font-semibold" v-render-emojis="identity?.account.emojis">{{
identity?.account.display_name identity?.account.display_name
}}</span> }}</span>
<span class="truncate text-xs">@{{ identity?.account.acct }}</span> <span class="truncate text-xs">@{{ identity?.account.acct }}</span>
@ -216,7 +216,7 @@ const instance = useInstance();
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar> </Avatar>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ <span class="truncate font-semibold" v-render-emojis="identity?.account.emojis">{{
identity?.account.display_name identity?.account.display_name
}}</span> }}</span>
<span class="truncate text-xs">@{{ <span class="truncate text-xs">@{{

49
plugins/EmojiRenderer.ts Normal file
View file

@ -0,0 +1,49 @@
import type { Emoji } from "@versia/client/types";
import { SettingIds } from "~/settings";
const emojisRegex =
/\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u200D(\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*/gu;
const incorrectEmojisRegex = /^[#*0-9©®]$/;
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive<HTMLElement, Emoji[]>("render-emojis", {
beforeMount(el, binding) {
const shouldRenderEmoji = useSetting(SettingIds.CustomEmojis);
const emojiFont = useSetting(SettingIds.EmojiTheme);
// Replace emoji shortcodes with images
if (shouldRenderEmoji.value.value) {
el.innerHTML = el.innerHTML.replace(
/:([a-zA-Z0-9_-]+):/g,
(match, emoji) => {
const emojiData = binding.value.find(
(e) => e.shortcode === emoji,
);
if (!emojiData) {
return match;
}
const image = document.createElement("img");
image.src = emojiData.url;
image.alt = `:${emoji}:`;
image.title = emojiData.shortcode;
image.className =
"h-[1lh] align-middle inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out";
return image.outerHTML;
},
);
}
if (emojiFont.value.value !== "native") {
el.innerHTML = el.innerHTML.replace(emojisRegex, (match) => {
if (incorrectEmojisRegex.test(match)) {
return match;
}
return `<img src="/emojis/${emojiFont}/${match}.svg" alt="${match}" class="h-[1em] inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out">`;
});
}
},
});
});

View file

@ -123,7 +123,7 @@ export const settings: Record<SettingIds, Setting> = {
} as EnumSetting, } as EnumSetting,
[SettingIds.CustomEmojis]: { [SettingIds.CustomEmojis]: {
title: "Render Custom Emojis", title: "Render Custom Emojis",
description: "Render custom emojis.", description: "Render custom emojis. Requires a page reload to apply.",
type: SettingType.Boolean, type: SettingType.Boolean,
value: true, value: true,
page: SettingPages.Behaviour, page: SettingPages.Behaviour,