diff --git a/src/components/HTMLContent.tsx b/src/components/HTMLContent.tsx deleted file mode 100644 index a94448c..0000000 --- a/src/components/HTMLContent.tsx +++ /dev/null @@ -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}); - } -} diff --git a/src/components/status/VulStatusContent.vue b/src/components/status/VulStatusContent.vue index d7686b3..2d7ffa5 100644 --- a/src/components/status/VulStatusContent.vue +++ b/src/components/status/VulStatusContent.vue @@ -3,7 +3,7 @@ import { ref } from "vue"; import { useI18n } from "vue-i18n"; import type Activity from "@/lib/api/entities/activity"; import { FwbButton } from "flowbite-vue"; -import HTMLContent from "../HTMLContent"; +import HTMLContent from "./content/HTMLContent"; const { status } = defineProps<{ status: Activity }>(); @@ -24,7 +24,6 @@ const isCollapsed = ref(!!status.spoiler_text); }} -
{ + 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 ( + <> + +
{dom}
+
+ {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 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; +} diff --git a/src/components/VulEmoji.vue b/src/components/status/content/VulEmoji.vue similarity index 52% rename from src/components/VulEmoji.vue rename to src/components/status/content/VulEmoji.vue index a986b4d..bd63a16 100644 --- a/src/components/VulEmoji.vue +++ b/src/components/status/content/VulEmoji.vue @@ -5,5 +5,12 @@ defineProps<{ emoji: CustomEmoji }>(); + + diff --git a/src/components/status/content/VulHashtag.vue b/src/components/status/content/VulHashtag.vue new file mode 100644 index 0000000..8983a20 --- /dev/null +++ b/src/components/status/content/VulHashtag.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/status/content/VulMention.vue b/src/components/status/content/VulMention.vue new file mode 100644 index 0000000..aecbcde --- /dev/null +++ b/src/components/status/content/VulMention.vue @@ -0,0 +1,11 @@ + + +