mirror of
https://github.com/defnull/fediwall.git
synced 2024-11-21 23:23:14 +01:00
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:
parent
5edb276dfe
commit
c57d9815d3
44
package-lock.json
generated
44
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -29,27 +29,26 @@ const playVideo = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="wall-item">
|
||||
<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?.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" >
|
||||
<source v-if="playVideo" :src="media.url">
|
||||
</video>
|
||||
<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">
|
||||
<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;
|
||||
|
@ -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,
|
||||
}
|
||||
|
38
src/utils.ts
38
src/utils.ts
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user