Replace mastodon emoji shortcodes with images

Replace :emoji: shortcodes with images in status bodys
and profile display names. Use non-animated version unless
video-autoplay is enabled.
This commit is contained in:
Marcel Hellkamp 2023-07-31 13:48:14 +02:00
parent 5edb276dfe
commit c57d9815d3
5 changed files with 134 additions and 27 deletions

44
package-lock.json generated
View File

@ -22,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.2.0", "@rushstack/eslint-patch": "^1.2.0",
"@tsconfig/node18": "^2.0.1", "@tsconfig/node18": "^2.0.1",
"@types/dompurify": "^3.0.2",
"@types/node": "^18.16.17", "@types/node": "^18.16.17",
"@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0", "@typescript-eslint/parser": "^6.2.0",
@ -29,6 +30,7 @@
"@vitejs/plugin-vue": "^4.2.3", "@vitejs/plugin-vue": "^4.2.3",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
"dompurify": "^3.0.5",
"eslint": "^8.45.0", "eslint": "^8.45.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"git-describe": "^4.1.1", "git-describe": "^4.1.1",
@ -2069,6 +2071,15 @@
"integrity": "sha512-UqdfvuJK0SArA2CxhKWwwAWfnVSXiYe63bVpMutc27vpngCntGUZQETO24pEJ46zU6XM+7SpqYoMgcO3bM11Ew==", "integrity": "sha512-UqdfvuJK0SArA2CxhKWwwAWfnVSXiYe63bVpMutc27vpngCntGUZQETO24pEJ46zU6XM+7SpqYoMgcO3bM11Ew==",
"dev": true "dev": true
}, },
"node_modules/@types/dompurify": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz",
"integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==",
"dev": true,
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.12", "version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
@ -2087,6 +2098,12 @@
"integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
"dev": true "dev": true
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==",
"dev": true
},
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.17", "version": "0.0.17",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz",
@ -3313,9 +3330,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.0.4", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-ae0mA+Qiqp6C29pqZX3fQgK+F91+F7wobM/v8DRzDqJdZJELXiFUx4PP4pK/mzUS0xkiSEx3Ncd9gr69jg3YsQ==" "integrity": "sha512-F9e6wPGtY+8KNMRAVfxeCOHU0/NPWMSENNq4pQctuXRqqdEPW7q3CrLbR5Nse044WwacyjHGOMlvNsBe1y6z9A=="
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.466", "version": "1.4.466",
@ -6820,6 +6837,15 @@
"integrity": "sha512-UqdfvuJK0SArA2CxhKWwwAWfnVSXiYe63bVpMutc27vpngCntGUZQETO24pEJ46zU6XM+7SpqYoMgcO3bM11Ew==", "integrity": "sha512-UqdfvuJK0SArA2CxhKWwwAWfnVSXiYe63bVpMutc27vpngCntGUZQETO24pEJ46zU6XM+7SpqYoMgcO3bM11Ew==",
"dev": true "dev": true
}, },
"@types/dompurify": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz",
"integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==",
"dev": true,
"requires": {
"@types/trusted-types": "*"
}
},
"@types/json-schema": { "@types/json-schema": {
"version": "7.0.12", "version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
@ -6838,6 +6864,12 @@
"integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
"dev": true "dev": true
}, },
"@types/trusted-types": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==",
"dev": true
},
"@types/web-bluetooth": { "@types/web-bluetooth": {
"version": "0.0.17", "version": "0.0.17",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz",
@ -7669,9 +7701,9 @@
} }
}, },
"dompurify": { "dompurify": {
"version": "3.0.4", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-ae0mA+Qiqp6C29pqZX3fQgK+F91+F7wobM/v8DRzDqJdZJELXiFUx4PP4pK/mzUS0xkiSEx3Ncd9gr69jg3YsQ==" "integrity": "sha512-F9e6wPGtY+8KNMRAVfxeCOHU0/NPWMSENNq4pQctuXRqqdEPW7q3CrLbR5Nse044WwacyjHGOMlvNsBe1y6z9A=="
}, },
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.4.466", "version": "1.4.466",

View File

@ -25,12 +25,14 @@
"bootstrap": "^5.3.0", "bootstrap": "^5.3.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"vue": "^3.3.4", "vue": "^3.3.4",
"dompurify": "^3.0.5",
"vue-dompurify-html": "^4.1.4", "vue-dompurify-html": "^4.1.4",
"vue-masonry": "^0.16.0" "vue-masonry": "^0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.2.0", "@rushstack/eslint-patch": "^1.2.0",
"@tsconfig/node18": "^2.0.1", "@tsconfig/node18": "^2.0.1",
"@types/dompurify": "^3.0.2",
"@types/node": "^18.16.17", "@types/node": "^18.16.17",
"@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0", "@typescript-eslint/parser": "^6.2.0",

View File

@ -32,24 +32,23 @@ const playVideo = computed(() => {
<div class="card mx-2 my-3" :class="post.pinned ? 'pinned' : ''"> <div class="card mx-2 my-3" :class="post.pinned ? 'pinned' : ''">
<div v-if="post.author" class="card-header d-flex align-items-center"> <div v-if="post.author" class="card-header d-flex align-items-center">
<div v-if="post.author?.avatar" class="flex-shrink-0"> <div v-if="post.author?.avatar" class="flex-shrink-0">
<img :src="post.author.avatar" class="rounded-circle me-2" /> <img :src="post.author.avatar" class="me-2 avatar" />
</div> </div>
<p class="flex-grow-1 m-0"> <a :href="post.author.url || post.url" target="_blank" v-dompurify-html="post.author.name"
<a v-if="post.author.url" :href="post.author.url" target="_blank" v-dompurify-html="post.author.name" class="text-body"></a> class="flex-grow-1 m-0 text-body"></a>
<span v-else v-dompurify-html="post.author.name"></span></p>
<slot name="topleft"></slot> <slot name="topleft"></slot>
</div> </div>
<div class="card-body"> <div class="card-body">
<div v-if="config.showMedia" class="wall-media mb-3"> <div v-if="config.showMedia" class="wall-media mb-3">
<img v-if="media?.type === 'image'" :src="media.url" :alt="media.alt"> <img v-if="media?.type === 'image'" :src="media.url" :alt="media.alt">
<video v-else-if="media?.type === 'video'" ref="videoElement" <video v-else-if="media?.type === 'video'" ref="videoElement" muted loop :autoplay="playVideo"
muted loop :autoplay="playVideo" :poster="media.preview" :alt="media.alt" > :poster="media.preview" :alt="media.alt">
<source v-if="playVideo" :src="media.url"> <source v-if="playVideo" :src="media.url">
</video> </video>
</div> </div>
<p v-if="config.showText" class="card-text" v-dompurify-html="post.content"></p> <p v-if="config.showText" class="card-text" v-dompurify-html="post.content"></p>
<p class="card-text text-end text-break"><a :href="post.url" target="_blank" <p class="card-text text-end text-break"><a :href="post.url" target="_blank" alt="${post.date}"
alt="${post.date}" class="text-decoration-none text-muted"><small>{{ timeAgo }}</small></a></p> class="text-decoration-none text-muted"><small>{{ timeAgo }}</small></a></p>
</div> </div>
</div> </div>
</div> </div>
@ -68,22 +67,28 @@ const playVideo = computed(() => {
text-decoration: none; text-decoration: none;
} }
.wall-item .card-header img { .wall-item img.avatar {
width: 2em; width: 2em;
height: 2em; height: 2em;
border-radius: 50%;
} }
.wall-media { .wall-item img.emoji {
height: 1em;
width: 1em;
object-fit: contain;
vertical-align: middle;
font-size: inherit;
} }
.wall-media img, .wall-media video { .wall-media img,
.wall-media video {
width: 100%; width: 100%;
max-height: 1wh; max-height: 1wh;
object-fit: cover; object-fit: cover;
border-radius: 5px; border-radius: 5px;
} }
.wall-item .invisible { .wall-item .invisible {
font-size: 0 !important; font-size: 0 !important;
line-height: 0 !important; line-height: 0 !important;

View File

@ -1,6 +1,7 @@
import type { Config, MastodonAccount, MastodonStatus, Post, PostMedia } from "@/types"; import type { Config, MastodonAccount, MastodonStatus, Post, PostMedia } from "@/types";
import { regexEscape } from "@/utils"; import { regexEscape } from "@/utils";
import { replaceInText } from '@/utils'
import DOMPurify from 'dompurify'
/** /**
* Fetch unique posts from all sources (curently only Mastodon is implemented) * Fetch unique posts from all sources (curently only Mastodon is implemented)
@ -83,7 +84,7 @@ export async function fetchPosts(cfg: Config): Promise<Post[]> {
try { try {
(await task()) (await task())
.filter(status => filterStatus(cfg, status)) .filter(status => filterStatus(cfg, status))
.map(statusToWallPost) .map(status => statusToWallPost(cfg, status))
.forEach(addOrRepacePost) .forEach(addOrRepacePost)
} catch (err) { } catch (err) {
console.warn(`Update task failed for domain ${domain}`, err) console.warn(`Update task failed for domain ${domain}`, err)
@ -204,12 +205,41 @@ const filterStatus = (cfg: Config, status: MastodonStatus) => {
/** /**
* Convert a mastdon status object to a Post. * Convert a mastdon status object to a Post.
*/ */
const statusToWallPost = (status: MastodonStatus): Post => { const statusToWallPost = (cfg: Config, status: MastodonStatus): Post => {
const date = status.created_at const date = status.created_at
if (status.reblog) if (status.reblog)
status = status.reblog status = status.reblog
const animate = cfg.playVideos;
const emojiPattern = /(?<=[^a-z0-9:]|^):([a-z0-9_]{2,}):(?=[^a-z0-9:]|$)/igmu
const replaceEmojis = (content: string, emojiMeta: Array<any>) => {
content = DOMPurify.sanitize(content)
if (emojiMeta.length) {
var tmpNode = document.createElement("div");
tmpNode.innerHTML = content
replaceInText(tmpNode, emojiPattern, m => {
const code = m[1];
const hit = emojiMeta.find(e => e.shortcode === code)
if (!hit || !hit.url) return m[0]
const img = document.createElement("img")
img.src = animate ? hit.url : hit.static_url || hit.url
img.classList.add("emoji")
img.alt = img.title = `:${code}:`
return img;
})
content = tmpNode.innerHTML
}
return content
}
const name = status.account.display_name
? replaceEmojis(status.account.display_name, status.account.emojis)
: status.account.username
const content = replaceEmojis(status.content, status.emojis)
const media = status.media_attachments?.map((m): PostMedia | undefined => { const media = status.media_attachments?.map((m): PostMedia | undefined => {
switch (m.type) { switch (m.type) {
case "image": case "image":
@ -227,11 +257,11 @@ const statusToWallPost = (status: MastodonStatus): Post => {
id: status.uri, id: status.uri,
url: status.url || status.uri, url: status.url || status.uri,
author: { author: {
name: status.account.display_name || status.account.username, name,
url: status.account.url, url: status.account.url,
avatar: status.account.avatar, avatar: status.account.avatar,
}, },
content: status.content, content,
date, date,
media, media,
} }

View File

@ -22,3 +22,41 @@ export function deepClone(obj: any) {
return window.structuredClone(obj) return window.structuredClone(obj)
return JSON.parse(JSON.stringify(obj)) return JSON.parse(JSON.stringify(obj))
} }
/**
* Find all text nodes and replace each occuences of a pattern with either
* a string or a new DOM node. Can be used to replace emojis with images or
* URLs with links.
*
* The root node is modifed in-place and then returned.
*/
export function replaceInText(root: Node, pattern: RegExp, replace: (m: RegExpMatchArray) => string | Node) {
const walk = (node: Node) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (!child.nodeValue) continue;
const text = child.nodeValue
const matches = Array.from(text.matchAll(pattern))
if (!matches.length) continue
const newChilds: (string | Node)[] = []
var lastEnd = 0
for (const m of matches) {
if (m.index && m.index > lastEnd)
newChilds.push(text.substring(lastEnd, m.index))
lastEnd = (m.index || 0) + m[0].length
newChilds.push(replace(m))
}
if (lastEnd < text.length)
newChilds.push(text.substring(lastEnd))
child.replaceWith(...newChilds);
} else {
walk(child);
}
}
}
walk(root)
return root;
}