diff --git a/src/components/byline.jsx b/src/components/byline.jsx
new file mode 100644
index 00000000..e98b07e0
--- /dev/null
+++ b/src/components/byline.jsx
@@ -0,0 +1,26 @@
+import { Trans } from '@lingui/react/macro';
+
+import Icon from './icon';
+import NameText from './name-text';
+
+function Byline({ authors, hidden, children }) {
+ if (hidden) return children;
+ if (!authors?.[0]?.account?.id) return children;
+ const author = authors[0].account;
+
+ return (
+
{
+ // // Remove target="_blank" from links
+ // dom.querySelectorAll('a.u-url[target="_blank"]').forEach((a) => {
+ // if (!/http/i.test(a.innerText.trim())) {
+ // a.removeAttribute('target');
+ // }
+ // });
+ // },
+ // }),
+ // }}
+ />
+ );
+ }; /*,
+ (oldProps, newProps) => {
+ const { post: oldPost } = oldProps;
+ const { post: newPost } = newProps;
+ return oldPost.content === newPost.content;
+ },
+);*/
+
+export default PostContent;
diff --git a/src/components/post-embed-modal.jsx b/src/components/post-embed-modal.jsx
new file mode 100644
index 00000000..25dc03a7
--- /dev/null
+++ b/src/components/post-embed-modal.jsx
@@ -0,0 +1,394 @@
+import { Trans, useLingui } from '@lingui/react/macro';
+import prettify from 'html-prettify';
+
+import emojifyText from '../utils/emojify-text';
+import showToast from '../utils/show-toast';
+import states, { statusKey } from '../utils/states';
+
+import Icon from './icon';
+
+function generateHTMLCode(post, instance, level = 0) {
+ const {
+ account: {
+ url: accountURL,
+ displayName,
+ acct,
+ 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
+ ? `
+
📊:
+
+ ${poll.options
+ .map(
+ (option) => `
+ -
+ ${option.title}
+ ${option.votesCount >= 0 ? ` (${option.votesCount})` : ''}
+
+ `,
+ )
+ .join('')}
+
`
+ : '') +
+ (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
+ ? URL.parse(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 = `

`;
+ } else if (isVideo) {
+ mediaHTML = `
+
+ ${description ? `
${description}` : ''}
+ `;
+ } else if (isAudio) {
+ mediaHTML = `
+
+ ${description ? `
${description}` : ''}
+ `;
+ } else {
+ mediaHTML = `
+
đź“„ ${
+ description || sourceMediaURL
+ }
+ `;
+ }
+
+ return `
${mediaHTML}`;
+ })
+ .join('\n')
+ : '');
+
+ const htmlCode = `
+
+ ${
+ spoilerText
+ ? `
+
+ ${spoilerText}
+ ${contentHTML}
+
+ `
+ : contentHTML
+ }
+
+
+ `;
+
+ return prettify(htmlCode);
+}
+
+function PostEmbedModal({ post, instance, onClose }) {
+ const { t } = useLingui();
+ 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 (
+
+ {!!onClose && (
+
+ )}
+
+
+
+ HTML Code
+
+
+
+ {!!mediaAttachments?.length && (
+
+ )}
+ {!!accountEmojis?.length && (
+
+
+ Account Emojis:
+
+
+ {accountEmojis.map((emoji) => {
+ return (
+ -
+
+
+
+ {' '}
+ :{emoji.shortcode}: (
+
+ URL
+
+ )
+ {emoji.staticUrl ? (
+ <>
+ {' '}
+ (
+
+ static URL
+
+ )
+ >
+ ) : null}
+
+ );
+ })}
+
+
+ )}
+ {!!emojis?.length && (
+
+
+ Emojis:
+
+
+ {emojis.map((emoji) => {
+ return (
+ -
+
+
+
+ {' '}
+ :{emoji.shortcode}: (
+
+ URL
+
+ )
+ {emoji.staticUrl ? (
+ <>
+ {' '}
+ (
+
+ static URL
+
+ )
+ >
+ ) : null}
+
+ );
+ })}
+
+
+ )}
+
+
+
+ Notes:
+
+
+ -
+
+ This is static, unstyled and scriptless. You may need to apply
+ your own styles and edit as needed.
+
+
+ -
+
+ Polls are not interactive, becomes a list with vote counts.
+
+
+ -
+
+ Media attachments can be images, videos, audios or any file
+ types.
+
+
+ -
+ Post could be edited or deleted later.
+
+
+
+
+
+ Preview
+
+
+
+
+ Note: This preview is lightly styled.
+
+
+
+
+ );
+}
+
+export default PostEmbedModal;
diff --git a/src/components/status-button.jsx b/src/components/status-button.jsx
new file mode 100644
index 00000000..5f726e0a
--- /dev/null
+++ b/src/components/status-button.jsx
@@ -0,0 +1,68 @@
+import { forwardRef } from 'preact/compat';
+import { useEffect, useState } from 'preact/hooks';
+
+import shortenNumber from '../utils/shorten-number';
+
+import Icon from './icon';
+
+const StatusButton = forwardRef((props, ref) => {
+ let {
+ checked,
+ count,
+ class: className,
+ title,
+ alt,
+ size,
+ icon,
+ iconSize = 'l',
+ onClick,
+ ...otherProps
+ } = props;
+ if (typeof title === 'string') {
+ title = [title, title];
+ }
+ if (typeof alt === 'string') {
+ alt = [alt, alt];
+ }
+
+ const [buttonTitle, setButtonTitle] = useState(title[0] || '');
+ const [iconAlt, setIconAlt] = useState(alt[0] || '');
+
+ useEffect(() => {
+ if (checked) {
+ setButtonTitle(title[1] || '');
+ setIconAlt(alt[1] || '');
+ } else {
+ setButtonTitle(title[0] || '');
+ setIconAlt(alt[0] || '');
+ }
+ }, [checked, title, alt]);
+
+ return (
+
+ );
+});
+
+export default StatusButton;
diff --git a/src/components/status-card.jsx b/src/components/status-card.jsx
new file mode 100644
index 00000000..c229babe
--- /dev/null
+++ b/src/components/status-card.jsx
@@ -0,0 +1,290 @@
+import '@justinribeiro/lite-youtube';
+
+import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
+import { useCallback, useEffect, useState } from 'preact/hooks';
+import { useSnapshot } from 'valtio';
+
+import getDomain from '../utils/get-domain';
+import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
+import states from '../utils/states';
+import unfurlMastodonLink from '../utils/unfurl-link';
+
+import Byline from './byline';
+import Icon from './icon';
+import RelativeTime from './relative-time';
+
+// "Post": Quote post + card link preview combo
+// Assume all links from these domains are "posts"
+// Mastodon links are "posts" too but they are converted to real quote posts and there's too many domains to check
+// This is just "Progressive Enhancement"
+function isCardPost(domain) {
+ return [
+ 'x.com',
+ 'twitter.com',
+ 'threads.net',
+ 'bsky.app',
+ 'bsky.brid.gy',
+ 'fed.brid.gy',
+ ].includes(domain);
+}
+
+function StatusCard({ card, selfReferential, selfAuthor, instance }) {
+ const snapStates = useSnapshot(states);
+ const {
+ blurhash,
+ title,
+ description,
+ html,
+ providerName,
+ providerUrl,
+ authorName,
+ authorUrl,
+ width,
+ height,
+ image,
+ imageDescription,
+ url,
+ type,
+ embedUrl,
+ language,
+ publishedAt,
+ authors,
+ } = card;
+
+ /* type
+ link = Link OEmbed
+ photo = Photo OEmbed
+ video = Video OEmbed
+ rich = iframe OEmbed. Not currently accepted, so won't show up in practice.
+ */
+
+ const hasText = title || providerName || authorName;
+ const isLandscape = width / height >= 1.2;
+ const size = isLandscape ? 'large' : '';
+
+ const [cardStatusURL, setCardStatusURL] = useState(null);
+ // const [cardStatusID, setCardStatusID] = useState(null);
+ useEffect(() => {
+ if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) {
+ unfurlMastodonLink(instance, url).then((result) => {
+ if (!result) return;
+ const { id, url } = result;
+ setCardStatusURL('#' + url);
+
+ // NOTE: This is for quote post
+ // (async () => {
+ // const { masto } = api({ instance });
+ // const status = await masto.v1.statuses.$select(id).fetch();
+ // saveStatus(status, instance);
+ // setCardStatusID(id);
+ // })();
+ });
+ }
+ }, [hasText, image, selfReferential]);
+
+ // if (cardStatusID) {
+ // return (
+ //
+ // );
+ // }
+
+ if (snapStates.unfurledLinks[url]) return null;
+
+ const hasIframeHTML = /