mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Implement custom emojis
This commit is contained in:
parent
bb5de77bb1
commit
348b1ba2b0
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
49
plugins/EmojiRenderer.ts
Normal 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">`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue