working HTML->JSX renderer with emoji, mentions, and hashtags

This commit is contained in:
sam 2023-12-20 23:36:40 +01:00
parent 882c2471e8
commit 0988ab55e2
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
6 changed files with 155 additions and 45 deletions

View file

@ -1,42 +0,0 @@
import * as htmlparser2 from "htmlparser2";
import type { Mention, Tag } from "@/lib/api/entities/activity";
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
import { Text, type ChildNode, Element } from "domhandler";
interface Props {
content: string;
emoji: CustomEmoji[];
tags?: Tag[];
mentions?: Mention[];
}
const parser = new DOMParser();
export default function HTMLContent(props: Props) {
let { content } = props;
// emoji are always in their shortcode form
props.emoji.forEach((emoji) => {
content = content.replace(new RegExp(`:${emoji.shortcode}:`, "g"), customEmojiElement(emoji));
});
const dom = htmlparser2.parseDocument(content);
const elements = parseElement(dom.children);
console.log(dom);
return elements;
}
const customEmojiElement = (emoji: CustomEmoji) => {
const elem = document.createElement("x-emoji");
elem.setAttribute("emoji", emoji.shortcode);
return elem.outerHTML;
};
function parseElement(elems: ChildNode[]) {
const out = [];
for (const elem of elems) {
if (elem instanceof Text) out.push(<>{elem.data}</>);
}
}

View file

@ -3,7 +3,7 @@ import { ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type Activity from "@/lib/api/entities/activity"; import type Activity from "@/lib/api/entities/activity";
import { FwbButton } from "flowbite-vue"; import { FwbButton } from "flowbite-vue";
import HTMLContent from "../HTMLContent"; import HTMLContent from "./content/HTMLContent";
const { status } = defineProps<{ status: Activity }>(); const { status } = defineProps<{ status: Activity }>();
@ -24,7 +24,6 @@ const isCollapsed = ref(!!status.spoiler_text);
}} }}
</FwbButton> </FwbButton>
</div> </div>
<div v-if="!isCollapsed" v-html="status.content || 'N/A'"></div>
<HTMLContent <HTMLContent
v-if="!isCollapsed && status.content" v-if="!isCollapsed && status.content"
:content="status.content" :content="status.content"

View file

@ -0,0 +1,124 @@
import * as htmlparser2 from "htmlparser2";
import { h } from "vue";
import type { Mention, Tag } from "@/lib/api/entities/activity";
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
import { Document, Text, type ChildNode, Element } from "domhandler";
import VulEmoji from "./VulEmoji.vue";
import VulMention from "./VulMention.vue";
import VulHashtag from "./VulHashtag.vue";
interface Props {
content: string;
emoji: CustomEmoji[];
tags?: Tag[];
mentions?: Mention[];
}
export default function HTMLContent(props: Props) {
let { content } = props;
// emoji are always in their shortcode form
props.emoji.forEach((emoji) => {
content = content.replace(new RegExp(`:${emoji.shortcode}:`, "g"), customEmojiElement(emoji));
});
const dom = htmlparser2.parseDocument(content);
const elements = parseElement(dom, props.emoji, props.tags || [], props.mentions || []);
return (
<>
<code>
<pre>{dom}</pre>
</code>
{elements}
</>
);
}
/** Create a custom emoji element and return its string representation.
* Akkoma server renders HTML but keeps emoji in their shortcode form,
* so we turn it into a fake custom element so parseElement knows what to do with it.
*/
const customEmojiElement = (emoji: CustomEmoji) => {
const elem = document.createElement("x-emoji");
elem.setAttribute("emoji", emoji.shortcode);
return elem.outerHTML;
};
/** Recursively parses an element into a JSX representation. */
function parseElement(elem: ChildNode, emoji: CustomEmoji[], tags: Tag[], mentions: Mention[]) {
const out = [] as JSX.Element[];
if (elem instanceof Document) {
// root, start parsing elements
out.push(...(elem.children as ChildNode[]).map((e) => parseElement(e, emoji, tags, mentions)).flat());
} else if (elem instanceof Text) {
// text node, just add it to the output unmodified
out.push(<>{elem.data}</>);
} else if (elem instanceof Element) {
console.log(elem.name, elem.attribs);
switch (elem.name) {
case "x-emoji":
// Turn <x-emoji> into a VulEmoji component
out.push(h(VulEmoji, { emoji: emoji.find((e) => e.shortcode === elem.attribs["emoji"]!)! }));
break;
case "span":
// check if this is a mention
if ("class" in elem.attribs && elem.attribs.class === "h-card") {
// if so, parse that mention and add a component for it
if (elem.children.length === 0) throw "Invalid mention, span with type h-card doesn't have child";
const userId = (elem.children[0] as Element).attribs["data-user"];
const user = mentions.find((m) => m.id === userId);
if (!user) throw "Invalid mention, user is not found in Status.mentions";
out.push(h(VulMention, { mention: user }));
} else {
// else, just treat it as a normal span
out.push(
h(
elem.name,
elem.attribs,
elem.children.map((e) => parseElement(e, emoji, tags, mentions)),
),
);
}
break;
case "a":
// check if this is a hashtag
if (elem.attribs.class == "hashtag") {
// if so, get the hashtag and add a VulHashtag component
const tagName = elem.attribs["data-tag"];
const tag = tags.find((t) => t.name === tagName);
if (!tag) throw "Invalid hashtag, tag is not found in Status.tags";
out.push(h(VulHashtag, { tag: tag }));
} else {
// else, just treat it as a normal element
out.push(
h(
elem.name,
{ ...elem.attribs, target: "_blank" },
elem.children.map((e) => parseElement(e, emoji, tags, mentions)),
),
);
}
break;
default:
out.push(
h(
elem.name,
elem.attribs,
elem.children.map((e) => parseElement(e, emoji, tags, mentions)),
),
);
break;
}
h(elem.name, {});
}
return out;
}

View file

@ -5,5 +5,12 @@ defineProps<{ emoji: CustomEmoji }>();
</script> </script>
<template> <template>
<img class="inline-emoji" :src="emoji.url" :alt="emoji.shortcode" /> <img class="emoji" :src="emoji.url" :alt="emoji.shortcode" :title="emoji.shortcode" />
</template> </template>
<style>
.emoji {
display: inline;
height: 38px;
}
</style>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import type { Tag } from "@/lib/api/entities/activity";
defineProps<{ tag: Tag }>();
</script>
<template>
<span class="text-sky-600 underline hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-400">
<RouterLink :to="`/tags/${tag.name}`">#{{ tag.name }}</RouterLink>
</span>
</template>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import type { Mention } from "@/lib/api/entities/activity";
defineProps<{ mention: Mention }>();
</script>
<template>
<span class="bg-gray-400 dark:bg-gray-700 hover:bg-gray-500 dark:hover:bg-gray-600 p-1 rounded-full">
<RouterLink :to="`/@${mention.acct}`">@{{ mention.acct }}</RouterLink>
</span>
</template>