working HTML->JSX renderer with emoji, mentions, and hashtags
This commit is contained in:
parent
882c2471e8
commit
14e58c1b3d
6 changed files with 156 additions and 45 deletions
|
@ -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}</>);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
||||||
|
|
125
src/components/status/content/HTMLContent.tsx
Normal file
125
src/components/status/content/HTMLContent.tsx
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
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(<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";
|
||||||
|
// add the mention component
|
||||||
|
out.push(<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(<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;
|
||||||
|
}
|
|
@ -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>
|
11
src/components/status/content/VulHashtag.vue
Normal file
11
src/components/status/content/VulHashtag.vue
Normal 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>
|
11
src/components/status/content/VulMention.vue
Normal file
11
src/components/status/content/VulMention.vue
Normal 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>
|
Loading…
Reference in a new issue