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

View File

@ -25,12 +25,14 @@
"bootstrap": "^5.3.0",
"moment": "^2.29.4",
"vue": "^3.3.4",
"dompurify": "^3.0.5",
"vue-dompurify-html": "^4.1.4",
"vue-masonry": "^0.16.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
"@tsconfig/node18": "^2.0.1",
"@types/dompurify": "^3.0.2",
"@types/node": "^18.16.17",
"@typescript-eslint/eslint-plugin": "^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 v-if="post.author" class="card-header d-flex align-items-center">
<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>
<p class="flex-grow-1 m-0">
<a v-if="post.author.url" :href="post.author.url" target="_blank" v-dompurify-html="post.author.name" class="text-body"></a>
<span v-else v-dompurify-html="post.author.name"></span></p>
<a :href="post.author.url || post.url" target="_blank" v-dompurify-html="post.author.name"
class="flex-grow-1 m-0 text-body"></a>
<slot name="topleft"></slot>
</div>
<div class="card-body">
<div v-if="config.showMedia" class="wall-media mb-3">
<img v-if="media?.type === 'image'" :src="media.url" :alt="media.alt">
<video v-else-if="media?.type === 'video'" ref="videoElement"
muted loop :autoplay="playVideo" :poster="media.preview" :alt="media.alt" >
<video v-else-if="media?.type === 'video'" ref="videoElement" muted loop :autoplay="playVideo"
:poster="media.preview" :alt="media.alt">
<source v-if="playVideo" :src="media.url">
</video>
</div>
<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"
alt="${post.date}" class="text-decoration-none text-muted"><small>{{ timeAgo }}</small></a></p>
<p class="card-text text-end text-break"><a :href="post.url" target="_blank" alt="${post.date}"
class="text-decoration-none text-muted"><small>{{ timeAgo }}</small></a></p>
</div>
</div>
</div>
@ -68,22 +67,28 @@ const playVideo = computed(() => {
text-decoration: none;
}
.wall-item .card-header img {
.wall-item img.avatar {
width: 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%;
max-height: 1wh;
object-fit: cover;
border-radius: 5px;
}
.wall-item .invisible {
font-size: 0 !important;
line-height: 0 !important;

View File

@ -1,6 +1,7 @@
import type { Config, MastodonAccount, MastodonStatus, Post, PostMedia } from "@/types";
import { regexEscape } from "@/utils";
import { replaceInText } from '@/utils'
import DOMPurify from 'dompurify'
/**
* Fetch unique posts from all sources (curently only Mastodon is implemented)
@ -83,7 +84,7 @@ export async function fetchPosts(cfg: Config): Promise<Post[]> {
try {
(await task())
.filter(status => filterStatus(cfg, status))
.map(statusToWallPost)
.map(status => statusToWallPost(cfg, status))
.forEach(addOrRepacePost)
} catch (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.
*/
const statusToWallPost = (status: MastodonStatus): Post => {
const statusToWallPost = (cfg: Config, status: MastodonStatus): Post => {
const date = status.created_at
if (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 => {
switch (m.type) {
case "image":
@ -227,11 +257,11 @@ const statusToWallPost = (status: MastodonStatus): Post => {
id: status.uri,
url: status.url || status.uri,
author: {
name: status.account.display_name || status.account.username,
name,
url: status.account.url,
avatar: status.account.avatar,
},
content: status.content,
content,
date,
media,
}

View File

@ -22,3 +22,41 @@ export function deepClone(obj: any) {
return window.structuredClone(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;
}