diff --git a/.gitignore b/.gitignore
index 61880e3..0c7b40f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,4 +22,6 @@ logs
.env
.env.*
!.env.example
-config
\ No newline at end of file
+config
+
+public/emojis
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index cb7bb48..a0666e5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index 908437a..f800c96 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/bun.lockb b/bun.lockb
index 30ba19a..00c7cd9 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/settings/types/Code.vue b/components/settings/types/Code.vue
index 2631119..f3868f3 100644
--- a/components/settings/types/Code.vue
+++ b/components/settings/types/Code.vue
@@ -6,6 +6,7 @@
+
{{ setting.description }}
diff --git a/components/settings/types/Enum.vue b/components/settings/types/Enum.vue
index 38be709..7126ff4 100644
--- a/components/settings/types/Enum.vue
+++ b/components/settings/types/Enum.vue
@@ -9,9 +9,10 @@
- Not
+
Not
implemented
+ {{ setting.description }}
-
+
{{ page }}
diff --git a/composables/ParsedContent.ts b/composables/ParsedContent.ts
index 88d5d24..233879d 100644
--- a/composables/ParsedContent.ts
+++ b/composables/ParsedContent.ts
@@ -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 ` `;
+ },
+ );
+ }
+
// Replace links containing mentions with interactive mentions
const links = contentHtml.querySelectorAll("a");
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 5bdbaa4..a8a74cd 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -247,6 +247,13 @@ export default defineNuxtConfig({
brotli: false,
gzip: false,
},
+ routeRules: {
+ "/emojis/**": {
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ },
+ },
},
schemaOrg: {
enabled: false,
diff --git a/package.json b/package.json
index 350215b..7e08290 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pages/settings/index.vue b/pages/settings/index.vue
index 3ba6ae8..b7b2ab5 100644
--- a/pages/settings/index.vue
+++ b/pages/settings/index.vue
@@ -10,11 +10,11 @@
SettingPages.Appearance,
)) as SettingIds[])" :key="id" />
-
+
diff --git a/settings.ts b/settings.ts
index c860df3..f8a0462 100644
--- a/settings.ts
+++ b/settings.ts
@@ -83,7 +83,7 @@ export enum SettingIds {
export const settings: Record = {
[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 = {
} 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 = {
} 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 = {
} 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.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 = {
} 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 = {
} 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 = {
} 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 = {
} 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 = {
},
{
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,
};
diff --git a/utils/emojis.ts b/utils/emojis.ts
new file mode 100644
index 0000000..405b4ec
--- /dev/null
+++ b/utils/emojis.ts
@@ -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": " " }
+ 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//.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);
+}