feat: Add emoji theme picker

This commit is contained in:
Jesse Wierzbinski 2024-11-05 14:25:28 +01:00
parent ac4acd31cb
commit 6b6d1d44d2
No known key found for this signature in database
13 changed files with 132 additions and 21 deletions

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ logs
.env.*
!.env.example
config
public/emojis

View file

@ -12,6 +12,7 @@ FROM base AS builder
COPY . /app
COPY --from=install /temp/dev/node_modules /app/node_modules
RUN cd /app && bun run emojis:generate
RUN cd /app && bun run build --preset node-server
FROM oven/bun:1.1.34-alpine AS final

View file

@ -81,6 +81,10 @@ services:
Then, the frontend will be available at `http://localhost:3000` inside the container. To link it to a Versia Server, set the `NUXT_PUBLIC_API_HOST` environment variable to the server's URL.
## Development
Make sure to run `bun run emojis:generate` to generate the emoji list before building or running the project.
## License
This project is licensed under the AGPL 3.0 - see the [LICENSE](LICENSE) file for details.

BIN
bun.lockb

Binary file not shown.

View file

@ -6,6 +6,7 @@
<textarea v-model="content"
class="resize-none min-h-48 mt-1 prose prose-invert max-w-full ring-1 ring-white/20 font-mono placeholder:text-zinc-500 bg-transparent rounded appearance-none disabled:cursor-not-allowed"
aria-label="Start typing here..."></textarea>
<p v-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
</div>
</template>

View file

@ -9,9 +9,10 @@
</Select.Indicator>
</Select.Trigger>
</Select.Control>
<p v-if="setting.notImplemented" class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not
<p v-if="setting.notImplemented" class="text-xs mt-2 row-start-3 text-red-300 font-semibold">Not
implemented
</p>
<p v-else-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
<Teleport to="body">
<Select.Positioner>
<Select.Content

View file

@ -1,7 +1,7 @@
<template>
<Tabs.Root v-model="tab">
<Tabs.List class="flex flex-row p-4 gap-4 bg-dark-800 relative ring-1 ring-white/5 overflow-x-auto">
<Tabs.Trigger :value="page" v-for="page of SettingPages" :as-child="true">
<Tabs.Trigger :value="page" v-for="page of [SettingPages.Account, SettingPages.Behaviour, SettingPages.Appearance]" :as-child="true">
<ButtonBase class="capitalize hover:bg-white/5">
{{ page }}
</ButtonBase>

View file

@ -3,6 +3,10 @@ import { renderToString } from "vue/server-renderer";
import { SettingIds, type Settings } from "~/settings";
import MentionComponent from "../components/social-elements/notes/mention.vue";
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©®]$/;
/**
* Takes in an HTML string, parses emojis and returns a reactive object with the parsed content.
* @param content String of HTML content to parse
@ -29,6 +33,7 @@ export const useParsedContent = (
const shouldRenderEmoji =
toValue(settings)?.[SettingIds.CustomEmojis].value;
const emojiFont = toValue(settings)?.[SettingIds.EmojiTheme].value;
// Replace emoji shortcodes with images
if (shouldRenderEmoji) {
@ -52,6 +57,19 @@ export const useParsedContent = (
);
}
if (emojiFont !== "native") {
contentHtml.innerHTML = contentHtml.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 transi''tion-transform duration-75 ease-in-out">`;
},
);
}
// Replace links containing mentions with interactive mentions
const links = contentHtml.querySelectorAll("a");

View file

@ -247,6 +247,13 @@ export default defineNuxtConfig({
brotli: false,
gzip: false,
},
routeRules: {
"/emojis/**": {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
},
},
},
},
schemaOrg: {
enabled: false,

View file

@ -23,7 +23,7 @@
"build": "nuxt build",
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun --bun nuxt dev --https --https.cert config/versia-fe.localhost.pem --https.key config/versia-fe.localhost-key.pem --host versia-fe.localhost",
"generate": "nuxt generate",
"preview": "nuxt preview",
"emojis:generate": "bun run utils/emojis.ts",
"postinstall": "nuxt prepare",
"lint": "bunx @biomejs/biome check .",
"check": "bunx tsc -p ."
@ -64,7 +64,12 @@
"@types/html-to-text": "^9.0.4",
"@vue-email/nuxt": "^0.8.19",
"typescript": "^5.6.3",
"vue-tsc": "^2.1.10"
"vue-tsc": "^2.1.10",
"@iconify-json/fluent-emoji": "^1.2.1",
"@iconify-json/fluent-emoji-flat": "^1.2.1",
"@iconify-json/noto": "^1.2.1",
"@iconify-json/twemoji": "^1.2.1",
"@iconify/utils": "^2.1.33"
},
"trustedDependencies": [
"@biomejs/biome",

View file

@ -10,11 +10,11 @@
SettingPages.Appearance,
)) as SettingIds[])" :key="id" />
</template>
<template #advanced>
<!-- <template #advanced>
<Renderer :id="id" v-for="id of (Object.keys(getSettingsForPage(
SettingPages.Advanced,
)) as SettingIds[])" :key="id" />
</template>
</template> -->
<template #account>
<ProfileEditor />
</template>

View file

@ -83,7 +83,7 @@ export enum SettingIds {
export const settings: Record<SettingIds, Setting> = {
[SettingIds.Mfm]: {
title: "Render MFM",
description: "Render Misskey-Flavoured Markdown",
description: "Render Misskey-Flavoured Markdown.",
type: SettingType.Boolean,
value: false,
page: SettingPages.Behaviour,
@ -91,7 +91,7 @@ export const settings: Record<SettingIds, Setting> = {
} as BooleanSetting,
[SettingIds.CustomCSS]: {
title: "Custom CSS",
description: "Custom CSS for the UI",
description: "Custom CSS for the UI.",
type: SettingType.Code,
value: "",
language: "css",
@ -99,7 +99,7 @@ export const settings: Record<SettingIds, Setting> = {
} as CodeSetting,
[SettingIds.Theme]: {
title: "Theme",
description: "UI theme",
description: "UI theme.",
type: SettingType.Enum,
value: "dark",
options: [
@ -121,21 +121,21 @@ export const settings: Record<SettingIds, Setting> = {
} as EnumSetting,
[SettingIds.CustomEmojis]: {
title: "Render Custom Emojis",
description: "Render custom emojis",
description: "Render custom emojis.",
type: SettingType.Boolean,
value: true,
page: SettingPages.Behaviour,
} as BooleanSetting,
[SettingIds.ShowContentWarning]: {
title: "Show Content Warning",
description: "Show content warnings on notes marked sensitive/spoiler",
description: "Show content warnings on notes marked sensitive/spoiler.",
type: SettingType.Boolean,
value: true,
page: SettingPages.Behaviour,
} as BooleanSetting,
[SettingIds.PopupAvatarHover]: {
title: "Popup Profile Hover",
description: "Show profile popup when hovering over a user's avatar",
description: "Show profile popup when hovering over a user's avatar.",
type: SettingType.Boolean,
value: true,
page: SettingPages.Behaviour,
@ -143,14 +143,14 @@ export const settings: Record<SettingIds, Setting> = {
[SettingIds.InfiniteScroll]: {
title: "Infinite Scroll",
description:
"Automatically load more notes when reaching the bottom of the page",
"Automatically load more notes when reaching the bottom of the page.",
type: SettingType.Boolean,
value: true,
page: SettingPages.Behaviour,
} as BooleanSetting,
[SettingIds.ConfirmDelete]: {
title: "Confirm Delete",
description: "Confirm before deleting a note",
description: "Confirm before deleting a note.",
type: SettingType.Boolean,
value: false,
page: SettingPages.Behaviour,
@ -158,7 +158,7 @@ export const settings: Record<SettingIds, Setting> = {
} as BooleanSetting,
[SettingIds.ConfirmFollow]: {
title: "Confirm Follow",
description: "Confirm before following/unfollowing a user",
description: "Confirm before following/unfollowing a user.",
type: SettingType.Boolean,
value: false,
page: SettingPages.Behaviour,
@ -166,7 +166,7 @@ export const settings: Record<SettingIds, Setting> = {
} as BooleanSetting,
[SettingIds.ConfirmReblog]: {
title: "Confirm Reblog",
description: "Confirm before reblogging a note",
description: "Confirm before reblogging a note.",
type: SettingType.Boolean,
value: false,
page: SettingPages.Behaviour,
@ -174,7 +174,7 @@ export const settings: Record<SettingIds, Setting> = {
} as BooleanSetting,
[SettingIds.ConfirmFavourite]: {
title: "Confirm Favourite",
description: "Confirm before favouriting a note",
description: "Confirm before favouriting a note.",
type: SettingType.Boolean,
value: false,
page: SettingPages.Behaviour,
@ -182,7 +182,8 @@ export const settings: Record<SettingIds, Setting> = {
} as BooleanSetting,
[SettingIds.EmojiTheme]: {
title: "Emoji Theme",
description: "Theme used for rendering emojis",
description:
"Theme used for rendering emojis. Requires a page reload to apply.",
type: SettingType.Enum,
value: "native",
options: [
@ -192,15 +193,22 @@ export const settings: Record<SettingIds, Setting> = {
},
{
value: "twemoji",
label: "Twitter emoji set",
label: "Twitter Emojis",
},
{
value: "noto",
label: "Noto Emoji",
},
{
value: "fluent",
label: "Fluent Emojis",
},
{
value: "fluent-flat",
label: "Fluent Emojis (flat version)",
},
],
page: SettingPages.Appearance,
notImplemented: true,
} as EnumSetting,
};

64
utils/emojis.ts Normal file
View file

@ -0,0 +1,64 @@
import { mkdir, writeFile } from "node:fs/promises";
import * as fluentEmojiData from "@iconify-json/fluent-emoji";
import * as fluentFlatEmojiData from "@iconify-json/fluent-emoji-flat";
import * as notoEmojiData from "@iconify-json/noto";
import * as twemojiData from "@iconify-json/twemoji";
import { getIconData, iconToHTML, iconToSVG, replaceIDs } from "@iconify/utils";
const emojiSets = {
twemoji: twemojiData,
noto: notoEmojiData,
fluent: fluentEmojiData,
"fluent-flat": fluentFlatEmojiData,
} as const;
const prerenderEmojis = (set: keyof typeof emojiSets) => {
const data = emojiSets[set];
// Outputs an object in the format { "emoji": "<svg ... />" }
const emojisToName = Object.entries(data.chars).map(([unicode, name]) => {
const emojiUnicode = String.fromCodePoint(
...unicode.split("-").map((c) => Number.parseInt(c, 16)),
);
return [emojiUnicode, name] as const;
});
// Get the SVG for each emoji
return Object.fromEntries(
emojisToName.map(([emoji, name]) => {
const iconData = getIconData(data.icons, name);
if (!iconData) {
throw new Error(`Icon not found: ${name}`);
}
const svg = iconToSVG(iconData, {
width: 64,
height: 64,
});
return [
emoji,
iconToHTML(replaceIDs(svg.body), svg.attributes),
] as const;
}),
);
};
// Pregenerates images for all sets and places them in public/emojis/<set>/<unicode_name>.svg
const pregenerateImages = async (set: keyof typeof emojiSets) => {
const emojis = prerenderEmojis(set);
const setDir = `public/emojis/${set}`;
await mkdir(setDir, { recursive: true });
for (const [emoji, svg] of Object.entries(emojis)) {
await writeFile(`${setDir}/${emoji}.svg`, svg);
}
};
for (const set of Object.keys(emojiSets) as (keyof typeof emojiSets)[]) {
pregenerateImages(set);
}