Experimental Embed post

This commit is contained in:
Lim Chee Aun 2024-03-02 18:55:05 +08:00
parent 8108151fb6
commit fcb0074f49
3 changed files with 475 additions and 0 deletions

View file

@ -101,4 +101,5 @@ export const ICONS = {
history: () => import('@iconify-icons/mingcute/history-2-line'),
document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
code: () => import('@iconify-icons/mingcute/code-line'),
};

View file

@ -2135,6 +2135,97 @@ a.card:is(:hover, :focus):visited {
pointer-events: none;
}
/* EMBED */
#embed-post {
> main > section {
p {
margin-block: 0.5em;
}
ul {
margin: 0;
padding-inline: 1em;
}
p + ul {
margin-top: 0;
padding-top: 0;
}
}
.embed-code {
width: 100%;
resize: vertical;
min-height: 12em;
max-height: 40vh;
font-family: var(--monospace-font);
font-size: 0.8em;
border-color: var(--link-color);
/* background-color: var(--bg-faded-color); */
}
.links-list {
a {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.embed-preview {
display: block;
max-height: 40vh;
overflow: auto;
font-size: 0.9em;
border: 2px dashed var(--link-light-color);
border-radius: 8px;
box-shadow: 0 4px 8px -4px var(--drop-shadow-color),
0 8px 32px -8px var(--drop-shadow-color);
padding: 16px;
/* Interactive elements */
button,
a,
video,
audio,
input,
select,
textarea,
iframe,
object,
embed {
pointer-events: none;
}
blockquote {
margin: 0 0 1em;
border-inline-start: 4px solid var(--outline-color);
padding-inline-start: 1em;
> p:first-child {
margin-top: 0;
}
}
ul,
ol {
margin-inline: 0;
padding-inline: 1em;
}
figure {
margin-inline: 0;
img,
video,
audio {
max-width: 100%;
height: auto;
}
}
}
}
/* DELETED */
.status-deleted {

View file

@ -10,6 +10,7 @@ import {
} from '@szhsin/react-menu';
import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
import { shallowEqual } from 'fast-equals';
import prettify from 'html-prettify';
import { memo } from 'preact/compat';
import {
useCallback,
@ -451,6 +452,7 @@ function Status({
]);
const [showEdited, setShowEdited] = useState(false);
const [showEmbed, setShowEmbed] = useState(false);
const spoilerContentRef = useTruncated();
const contentRef = useTruncated();
@ -935,6 +937,16 @@ function Status({
</MenuItem>
)}
</div>
{isSizeLarge && (
<MenuItem
onClick={() => {
setShowEmbed(true);
}}
>
<Icon icon="code" />
<span>Embed</span>
</MenuItem>
)}
{(isSelf || mentionSelf) && <MenuDivider />}
{(isSelf || mentionSelf) && (
<MenuItem
@ -1994,6 +2006,24 @@ function Status({
/>
</Modal>
)}
{!!showEmbed && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEmbed(false);
}
}}
>
<EmbedModal
post={status}
instance={instance}
onClose={() => {
setShowEmbed(false);
}}
/>
</Modal>
)}
</article>
</>
);
@ -2298,6 +2328,359 @@ function EditedAtModal({
);
}
function generateHTMLCode(post, instance, level = 0) {
const {
account: {
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
poll,
spoilerText,
language,
editedAt,
createdAt,
content,
mediaAttachments,
url,
emojis,
} = post;
const sKey = statusKey(id, instance);
const quotes = states.statusQuotes[sKey] || [];
const uniqueQuotes = quotes.filter(
(q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i,
);
const quoteStatusesHTML =
uniqueQuotes.length && level <= 2
? uniqueQuotes
.map((quote) => {
const { id, instance } = quote;
const sKey = statusKey(id, instance);
const s = states.statuses[sKey];
if (s) {
return generateHTMLCode(s, instance, ++level);
}
})
.join('')
: '';
const createdAtDate = new Date(createdAt);
// const editedAtDate = editedAt && new Date(editedAt);
const contentHTML =
emojifyText(content, emojis) +
'\n' +
quoteStatusesHTML +
'\n' +
(poll?.options?.length
? `
<p>📊:</p>
<ul>
${poll.options
.map(
(option) => `
<li>
${option.title}
${option.votesCount >= 0 ? ` (${option.votesCount})` : ''}
</li>
`,
)
.join('')}
</ul>`
: '') +
(mediaAttachments.length > 0
? '\n' +
mediaAttachments
.map((media) => {
const {
description,
meta,
previewRemoteUrl,
previewUrl,
remoteUrl,
url,
type,
} = media;
const { original = {}, small } = meta || {};
const width = small?.width || original?.width;
const height = small?.height || original?.height;
// Prefer remote over original
const sourceMediaURL = remoteUrl || url;
const previewMediaURL = previewRemoteUrl || previewUrl;
const mediaURL = previewMediaURL || sourceMediaURL;
const sourceMediaURLObj = sourceMediaURL
? new URL(sourceMediaURL)
: null;
const isVideoMaybe =
type === 'unknown' &&
sourceMediaURLObj &&
/\.(mp4|m4r|m4v|mov|webm)$/i.test(sourceMediaURLObj.pathname);
const isAudioMaybe =
type === 'unknown' &&
sourceMediaURLObj &&
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(sourceMediaURLObj.pathname);
const isImage =
type === 'image' ||
(type === 'unknown' &&
previewMediaURL &&
!isVideoMaybe &&
!isAudioMaybe);
const isVideo = type === 'gifv' || type === 'video' || isVideoMaybe;
const isAudio = type === 'audio' || isAudioMaybe;
let mediaHTML = '';
if (isImage) {
mediaHTML = `<img src="${mediaURL}" width="${width}" height="${height}" alt="${description}" loading="lazy" />`;
} else if (isVideo) {
mediaHTML = `
<video src="${sourceMediaURL}" width="${width}" height="${height}" controls preload="auto" poster="${previewMediaURL}" loading="lazy"></video>
${description ? `<figcaption>${description}</figcaption>` : ''}
`;
} else if (isAudio) {
mediaHTML = `
<audio src="${sourceMediaURL}" controls preload="auto"></audio>
${description ? `<figcaption>${description}</figcaption>` : ''}
`;
} else {
mediaHTML = `
<a href="${sourceMediaURL}">📄 ${
description || sourceMediaURL
}</a>
`;
}
return `<figure>${mediaHTML}</figure>`;
})
.join('\n')
: '');
const htmlCode = `
<blockquote lang="${language}" cite="${url}">
${
spoilerText
? `
<details>
<summary>${spoilerText}</summary>
${contentHTML}
</details>
`
: contentHTML
}
<footer>
${emojifyText(
displayName,
accountEmojis,
)} (@${username}) <a href="${url}"><time datetime="${createdAtDate.toISOString()}">${createdAtDate.toLocaleString()}</time></a>
</footer>
</blockquote>
`;
return prettify(htmlCode);
}
function EmbedModal({ post, instance, onClose }) {
const {
account: {
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
poll,
spoilerText,
language,
editedAt,
createdAt,
content,
mediaAttachments,
url,
emojis,
} = post;
const htmlCode = generateHTMLCode(post, instance);
return (
<div id="embed-post" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>Embed post</h2>
</header>
<main tabIndex="-1">
<h3>HTML Code</h3>
<textarea
class="embed-code"
readonly
onClick={(e) => {
e.target.select();
}}
>
{htmlCode}
</textarea>
<button
type="button"
onClick={() => {
try {
navigator.clipboard.writeText(htmlCode);
showToast('HTML code copied');
} catch (e) {
console.error(e);
showToast('Unable to copy HTML code');
}
}}
>
<Icon icon="clipboard" /> <span>Copy</span>
</button>
{!!mediaAttachments?.length && (
<section>
<p>Media attachments:</p>
<ol class="links-list">
{mediaAttachments.map((media) => {
return (
<li key={media.id}>
<a
href={media.remoteUrl || media.url}
target="_blank"
download
>
{media.remoteUrl || media.url}
</a>
</li>
);
})}
</ol>
</section>
)}
{!!accountEmojis?.length && (
<section>
<p>Account Emojis:</p>
<ul class="links-list">
{accountEmojis.map((emoji) => {
return (
<li key={emoji.shortcode}>
<picture>
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
></source>
<img
class="shortcode-emoji emoji"
src={emoji.url}
alt={`:${emoji.shortcode}:`}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>{' '}
<code>:{emoji.shortcode}:</code> (
<a href={emoji.url} target="_blank" download>
url
</a>
)
{emoji.staticUrl ? (
<>
{' '}
(
<a href={emoji.staticUrl} target="_blank" download>
static
</a>
)
</>
) : null}
</li>
);
})}
</ul>
</section>
)}
{!!emojis?.length && (
<section>
<p>Emojis:</p>
<ul class="links-list">
{emojis.map((emoji) => {
return (
<li key={emoji.shortcode}>
<picture>
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
></source>
<img
class="shortcode-emoji emoji"
src={emoji.url}
alt={`:${emoji.shortcode}:`}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>{' '}
<code>:{emoji.shortcode}:</code> (
<a href={emoji.url} target="_blank" download>
url
</a>
)
{emoji.staticUrl ? (
<>
{' '}
(
<a href={emoji.staticUrl} target="_blank" download>
static
</a>
)
</>
) : null}
</li>
);
})}
</ul>
</section>
)}
<section>
<small>
<p>Notes:</p>
<ul>
<li>
This is static, unstyled and scriptless. You may need to apply
your own styles and edit as needed.
</li>
<li>
Polls are not interactive, becomes a list with vote counts.
</li>
<li>
Media attachments can be images, videos, audios or any file
types.
</li>
<li>Post could be edited or deleted later.</li>
</ul>
</small>
</section>
<h3>Preview</h3>
<output
class="embed-preview"
dangerouslySetInnerHTML={{ __html: htmlCode }}
/>
<p>
<small>Note: This preview is lightly styled.</small>
</p>
</main>
</div>
);
}
function StatusButton({
checked,
count,