From db2b9d26690faa1404291bdc613afebf2f9654cc Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 27 Aug 2024 13:21:01 +0900 Subject: [PATCH] wip --- .../frontend-embed/src/components/EmMfm.ts | 466 ++++++++++++++++++ .../frontend-embed/src/components/EmNote.vue | 7 +- .../src/components/EmNoteDetailed.vue | 5 +- .../src/components/EmNoteSimple.vue | 3 +- .../src/components/EmNoteSub.vue | 3 +- .../frontend-embed/src/components/EmPoll.vue | 3 +- .../src/components/EmSubNoteContent.vue | 3 +- .../frontend-embed/src/components/EmUrl.vue | 90 ++++ packages/frontend-embed/src/instance.ts | 14 +- .../frontend-embed/src/to-be-shared/nyaize.ts | 27 + .../src/to-be-shared/safe-parse.ts | 11 + .../src/to-be-shared/safe-uri-decode.ts | 12 + 12 files changed, 629 insertions(+), 15 deletions(-) create mode 100644 packages/frontend-embed/src/components/EmMfm.ts create mode 100644 packages/frontend-embed/src/components/EmUrl.vue create mode 100644 packages/frontend-embed/src/to-be-shared/nyaize.ts create mode 100644 packages/frontend-embed/src/to-be-shared/safe-parse.ts create mode 100644 packages/frontend-embed/src/to-be-shared/safe-uri-decode.ts diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts new file mode 100644 index 0000000000..dabef6d022 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -0,0 +1,466 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { VNode, h, SetupContext, provide } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import EmUrl from '@/components/EmUrl.vue'; +import EmTime from '@/components/EmTime.vue'; +import EmLink from '@/components/EmLink.vue'; +import EmMention from '@/components/EmMention.vue'; +import EmEmoji from '@/components/EmEmoji.vue'; +import EmCustomEmoji from '@/components/EmCustomEmoji.vue'; +import EmCode from '@/components/EmCode.vue'; +import EmCodeInline from '@/components/EmCodeInline.vue'; +import EmGoogle from '@/components/EmGoogle.vue'; +import EmSparkle from '@/components/EmSparkle.vue'; +import EmA from '@/components/EmA.vue'; +import { host } from '@/config.js'; +import { nyaize as doNyaize } from '@/to-be-shared/nyaize.js'; +import { safeParseFloat } from '@/to-be-shared/safe-parse.js'; + +const QUOTE_STYLE = ` +display: block; +margin: 8px; +padding: 6px 0 6px 12px; +color: var(--fg); +border-left: solid 3px var(--fg); +opacity: 0.7; +`.split('\n').join(' '); + +type MfmProps = { + text: string; + plain?: boolean; + nowrap?: boolean; + author?: Misskey.entities.UserLite; + isNote?: boolean; + emojiUrls?: Record; + rootScale?: number; + nyaize?: boolean | 'respect'; + parsedNodes?: mfm.MfmNode[] | null; + enableEmojiMenu?: boolean; + enableEmojiMenuReaction?: boolean; + linkNavigationBehavior?: EmABehavior; +}; + +type MfmEvents = { + clickEv(id: string): void; +}; + +// eslint-disable-next-line import/no-default-export +export default function (props: MfmProps, { emit }: { emit: SetupContext['emit'] }) { + provide('linkNavigationBehavior', props.linkNavigationBehavior); + + const isNote = props.isNote ?? true; + const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.text == null || props.text === '') return; + + const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text); + + const validTime = (t: string | boolean | null | undefined) => { + if (t == null) return null; + if (typeof t === 'boolean') return null; + return t.match(/^\-?[0-9.]+s$/) ? t : null; + }; + + const validColor = (c: unknown): string | null => { + if (typeof c !== 'string') return null; + return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; + }; + + const useAnim = true; + + /** + * Gen Vue Elements from MFM AST + * @param ast MFM AST + * @param scale How times large the text is + * @param disableNyaize Whether nyaize is disabled or not + */ + const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => { + switch (token.type) { + case 'text': { + let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + if (!disableNyaize && shouldNyaize) { + text = doNyaize(text); + } + + if (!props.plain) { + const res: (VNode | string)[] = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children, scale))]; + } + + case 'strike': { + return [h('del', genEl(token.children, scale))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;', + }, genEl(token.children, scale)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style: string | undefined; + switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) ?? '1.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + return h('span', { + class: 'mfm-x2', + }, genEl(token.children, scale * 2)); + } + case 'x3': { + return h('span', { + class: 'mfm-x3', + }, genEl(token.children, scale * 3)); + } + case 'x4': { + return h('span', { + class: 'mfm-x4', + }, genEl(token.children, scale * 4)); + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children, scale)); + } + case 'rainbow': { + if (!useAnim) { + return h('span', { + class: '_mfm_rainbow_fallback_', + }, genEl(token.children, scale)); + } + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`; + break; + } + case 'sparkle': { + if (!useAnim) { + return genEl(token.children, scale); + } + return h(EmSparkle, {}, genEl(token.children, scale)); + } + case 'rotate': { + const degrees = safeParseFloat(token.props.args.deg) ?? 90; + style = `transform: rotate(${degrees}deg); transform-origin: center center;`; + break; + } + case 'position': { + const x = safeParseFloat(token.props.args.x) ?? 0; + const y = safeParseFloat(token.props.args.y) ?? 0; + style = `transform: translateX(${x}em) translateY(${y}em);`; + break; + } + case 'scale': { + const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5); + const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5); + style = `transform: scale(${x}, ${y});`; + scale = scale * Math.max(x, y); + break; + } + case 'fg': { + let color = validColor(token.props.args.color); + color = color ?? 'f00'; + style = `color: #${color}; overflow-wrap: anywhere;`; + break; + } + case 'bg': { + let color = validColor(token.props.args.color); + color = color ?? 'f00'; + style = `background-color: #${color}; overflow-wrap: anywhere;`; + break; + } + case 'border': { + let color = validColor(token.props.args.color); + color = color ? `#${color}` : 'var(--accent)'; + let b_style = token.props.args.style; + if ( + typeof b_style !== 'string' || + !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] + .includes(b_style) + ) b_style = 'solid'; + const width = safeParseFloat(token.props.args.width) ?? 1; + const radius = safeParseFloat(token.props.args.radius) ?? 0; + style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`; + break; + } + case 'ruby': { + if (token.children.length === 1) { + const child = token.children[0]; + let text = child.type === 'text' ? child.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = doNyaize(text); + } + return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]); + } else { + const rt = token.children.at(-1)!; + let text = rt.type === 'text' ? rt.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = doNyaize(text); + } + return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); + } + } + case 'unixtime': { + const child = token.children[0]; + const unixtime = parseInt(child.type === 'text' ? child.props.text : ''); + return h('span', { + style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;', + }, [ + h('i', { + class: 'ti ti-clock', + style: 'margin-right: 0.25em;', + }), + h(EmTime, { + key: Math.random(), + time: unixtime * 1000, + mode: 'detail', + }), + ]); + } + case 'clickable': { + return h('span', { onClick(ev: MouseEvent): void { + ev.stopPropagation(); + ev.preventDefault(); + const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; + emit('clickEv', clickEv); + } }, genEl(token.children, scale)); + } + } + if (style === undefined) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); + } else { + return h('span', { + style: 'display: inline-block; ' + style, + }, genEl(token.children, scale)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;', + }, genEl(token.children, scale))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;', + }, genEl(token.children, scale))]; + } + + case 'url': { + return [h(EmUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(EmLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children, scale, true))]; + } + + case 'mention': { + return [h(EmMention, { + key: Math.random(), + host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host, + username: token.props.username, + })]; + } + + case 'hashtag': { + return [h(EmA, { + key: Math.random(), + to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);', + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h(EmCode, { + key: Math.random(), + code: token.props.code, + lang: token.props.lang ?? undefined, + })]; + } + + case 'inlineCode': { + return [h(EmCodeInline, { + key: Math.random(), + code: token.props.code, + })]; + } + + case 'quote': { + if (!props.nowrap) { + return [h('div', { + style: QUOTE_STYLE, + }, genEl(token.children, scale, true))]; + } else { + return [h('span', { + style: QUOTE_STYLE, + }, genEl(token.children, scale, true))]; + } + } + + case 'emojiCode': { + if (props.author?.host == null) { + return [h(EmCustomEmoji, { + key: Math.random(), + name: token.props.name, + normal: props.plain, + host: null, + useOriginalSize: scale >= 2.5, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, + fallbackToImage: false, + })]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) { + return [h('span', `:${token.props.name}:`)]; + } else { + return [h(EmCustomEmoji, { + key: Math.random(), + name: token.props.name, + url: props.emojiUrls && props.emojiUrls[token.props.name], + normal: props.plain, + host: props.author.host, + useOriginalSize: scale >= 2.5, + })]; + } + } + } + + case 'unicodeEmoji': { + return [h(EmEmoji, { + key: Math.random(), + emoji: token.props.emoji, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, + })]; + } + + case 'mathInline': { + return [h('code', token.props.formula)]; + } + + case 'mathBlock': { + return [h('code', token.props.formula)]; + } + + case 'search': { + return [h(EmGoogle, { + key: Math.random(), + q: token.props.query, + })]; + } + + case 'plain': { + return [h('span', genEl(token.children, scale, true))]; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('unrecognized ast type:', (token as any).type); + + return []; + } + } + }).flat(Infinity) as (VNode | string)[]; + + return h('span', { + // https://codeday.me/jp/qa/20190424/690106.html + style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;', + }, genEl(rootAst, props.rootScale ?? 1)); +} diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue index 7219d683c8..d58d51fd36 100644 --- a/packages/frontend-embed/src/components/EmNote.vue +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -46,14 +46,14 @@ SPDX-License-Identifier: AGPL-3.0-only

- + {{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}

({{ i18n.ts.private }}) -
{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: - +
@@ -120,6 +120,7 @@ import EmNoteSimple from '@/components/EmNoteSimple.vue'; import EmReactionsViewer from '@/components/EmReactionsViewer.vue'; import EmMediaList from '@/components/EmMediaList.vue'; import EmPoll from '@/components/EmPoll.vue'; +import EmMfm from '@/components/EmMfm.js'; import { userPage } from '@/utils.js'; import { i18n } from '@/i18n.js'; import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index 9db9ddf7c4..507c42f711 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -58,13 +58,13 @@ SPDX-License-Identifier: AGPL-3.0-only

- + {{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}

({{ i18n.ts.private }}) -

- + {{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}

@@ -26,6 +26,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import EmNoteHeader from '@/components/EmNoteHeader.vue'; import EmSubNoteContent from '@/components/EmSubNoteContent.vue'; +import EmMfm from '@/components/EmMfm.js'; const props = defineProps<{ note: Misskey.entities.Note; diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index 411bf60d9d..a17e79eb3a 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only

- + {{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}

@@ -38,6 +38,7 @@ import EmSubNoteContent from '@/components/EmSubNoteContent.vue'; import { notePage } from '@/utils.js'; import { misskeyApi } from '@/misskey-api.js'; import { i18n } from '@/i18n.js'; +import EmMfm from '@/components/EmMfm.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue index add3125ee2..db0c770064 100644 --- a/packages/frontend-embed/src/components/EmPoll.vue +++ b/packages/frontend-embed/src/components/EmPoll.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- + ({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }}) @@ -33,6 +33,7 @@ import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { i18n } from '@/i18n.js'; import { host } from '@/config.js'; import { useInterval } from '@/to-be-shared/use-interval.js'; +import EmMfm from '@/components/EmMfm.js'; function sum(xs: number[]): number { return xs.reduce((a, b) => a + b, 0); diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue index 0622c059d3..02b7310af1 100644 --- a/packages/frontend-embed/src/components/EmSubNoteContent.vue +++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only ({{ i18n.ts.private }}) ({{ i18n.ts.deletedNote }}) - + RN: ...
@@ -36,6 +36,7 @@ import EmMediaList from '@/components/EmMediaList.vue'; import EmPoll from '@/components/EmPoll.vue'; import { i18n } from '@/i18n.js'; import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; +import EmMfm from '@/components/EmMfm.js'; const props = defineProps<{ note: Misskey.entities.Note; diff --git a/packages/frontend-embed/src/components/EmUrl.vue b/packages/frontend-embed/src/components/EmUrl.vue new file mode 100644 index 0000000000..f13ede5dfe --- /dev/null +++ b/packages/frontend-embed/src/components/EmUrl.vue @@ -0,0 +1,90 @@ + + + + + + + diff --git a/packages/frontend-embed/src/instance.ts b/packages/frontend-embed/src/instance.ts index 3b8c7324b2..970364667a 100644 --- a/packages/frontend-embed/src/instance.ts +++ b/packages/frontend-embed/src/instance.ts @@ -3,11 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, reactive } from 'vue'; -import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/misskey-api.js'; -export const instance = { - iconUrl: 'TODO', - mediaProxy: 'TODO', -}; +const providedMetaEl = document.getElementById('misskey_meta'); + +const _instance = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null; + +// NOTE: devモードのときしか _instance が null になることは無い +export const instance = _instance ?? await misskeyApi('meta', { + detail: true, +}); diff --git a/packages/frontend-embed/src/to-be-shared/nyaize.ts b/packages/frontend-embed/src/to-be-shared/nyaize.ts new file mode 100644 index 0000000000..abc8ada461 --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/nyaize.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const enRegex1 = /(?<=n)a/gi; +const enRegex2 = /(?<=morn)ing/gi; +const enRegex3 = /(?<=every)one/gi; +const koRegex1 = /[나-낳]/g; +const koRegex2 = /(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm; +const koRegex3 = /(야(?=\?))|(야$)|(야(?= ))/gm; + +export function nyaize(text: string): string { + return text + // ja-JP + .replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ') + // en-US + .replace(enRegex1, x => x === 'A' ? 'YA' : 'ya') + .replace(enRegex2, x => x === 'ING' ? 'YAN' : 'yan') + .replace(enRegex3, x => x === 'ONE' ? 'NYAN' : 'nyan') + // ko-KR + .replace(koRegex1, match => String.fromCharCode( + match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0), + )) + .replace(koRegex2, '다냥') + .replace(koRegex3, '냥'); +} diff --git a/packages/frontend-embed/src/to-be-shared/safe-parse.ts b/packages/frontend-embed/src/to-be-shared/safe-parse.ts new file mode 100644 index 0000000000..6bfcef6c36 --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/safe-parse.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function safeParseFloat(str: unknown): number | null { + if (typeof str !== 'string' || str === '') return null; + const num = parseFloat(str); + if (isNaN(num)) return null; + return num; +} diff --git a/packages/frontend-embed/src/to-be-shared/safe-uri-decode.ts b/packages/frontend-embed/src/to-be-shared/safe-uri-decode.ts new file mode 100644 index 0000000000..0edf4e9eba --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/safe-uri-decode.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +}