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 ( +
+ {children} +
+ {' '} + + + More from + + +
+
+ ); +} + +export default Byline; diff --git a/src/components/math-block.jsx b/src/components/math-block.jsx new file mode 100644 index 00000000..47994c41 --- /dev/null +++ b/src/components/math-block.jsx @@ -0,0 +1,164 @@ +import 'temml/dist/Temml-Local.css'; + +import { useLingui } from '@lingui/react/macro'; +import { useCallback, useState } from 'preact/hooks'; + +import showToast from '../utils/show-toast'; + +import Icon from './icon'; + +// Follow https://mathstodon.xyz/about +// > You can use LaTeX in toots here! Use \( and \) for inline, and \[ and \] for display mode. +const DELIMITERS_PATTERNS = [ + // '\\$\\$[\\s\\S]*?\\$\\$', // $$...$$ + '\\\\\\[[\\s\\S]*?\\\\\\]', // \[...\] + '\\\\\\([\\s\\S]*?\\\\\\)', // \(...\) + // '\\\\begin\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}[\\s\\S]*?\\\\end\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}', // AMS environments + // '\\\\(?:ref|eqref)\\{[^}]*\\}', // \ref{...}, \eqref{...} +]; +const DELIMITERS_REGEX = new RegExp(DELIMITERS_PATTERNS.join('|'), 'g'); + +function cleanDOMForTemml(dom) { + // Define start and end delimiter patterns + const START_DELIMITERS = ['\\\\\\[', '\\\\\\(']; // \[ and \( + const startRegex = new RegExp(`(${START_DELIMITERS.join('|')})`); + + // Walk through all text nodes + const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT); + const textNodes = []; + let node; + while ((node = walker.nextNode())) { + textNodes.push(node); + } + + for (const textNode of textNodes) { + const text = textNode.textContent; + const startMatch = text.match(startRegex); + + if (!startMatch) continue; // No start delimiter in this text node + + // Find the matching end delimiter + const startDelimiter = startMatch[0]; + const endDelimiter = startDelimiter === '\\[' ? '\\]' : '\\)'; + + // Collect nodes from start delimiter until end delimiter + const nodesToCombine = [textNode]; + let currentNode = textNode; + let foundEnd = false; + let combinedText = text; + + // Check if end delimiter is in the same text node + if (text.includes(endDelimiter)) { + foundEnd = true; + } else { + // Look through sibling nodes + while (currentNode.nextSibling && !foundEnd) { + const nextSibling = currentNode.nextSibling; + + if (nextSibling.nodeType === Node.TEXT_NODE) { + nodesToCombine.push(nextSibling); + combinedText += nextSibling.textContent; + if (nextSibling.textContent.includes(endDelimiter)) { + foundEnd = true; + } + } else if ( + nextSibling.nodeType === Node.ELEMENT_NODE && + nextSibling.tagName === 'BR' + ) { + nodesToCombine.push(nextSibling); + combinedText += '\n'; + } else { + // Found a non-BR element, stop and don't process + break; + } + + currentNode = nextSibling; + } + } + + // Only process if we found the end delimiter and have nodes to combine + if (foundEnd && nodesToCombine.length > 1) { + // Replace the first text node with combined text + textNode.textContent = combinedText; + + // Remove the other nodes + for (let i = 1; i < nodesToCombine.length; i++) { + nodesToCombine[i].remove(); + } + } + } +} + +const MathBlock = ({ content, contentRef, onRevert }) => { + DELIMITERS_REGEX.lastIndex = 0; // Reset index to prevent g trap + const hasLatexContent = DELIMITERS_REGEX.test(content); + + if (!hasLatexContent) return null; + + const { t } = useLingui(); + const [mathRendered, setMathRendered] = useState(false); + const toggleMathRendering = useCallback( + async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (mathRendered) { + // Revert to original content by refreshing PostContent + setMathRendered(false); + onRevert(); + } else { + // Render math + try { + // This needs global because the codebase inside temml is calling a function from global.temml 🤦‍♂️ + const temml = + window.temml || (window.temml = (await import('temml'))?.default); + + cleanDOMForTemml(contentRef.current); + const originalContentRefHTML = contentRef.current.innerHTML; + temml.renderMathInElement(contentRef.current, { + fences: '(', // This should sync with DELIMITERS_REGEX + annotate: true, + throwOnError: true, + errorCallback: (err) => { + console.warn('Failed to render LaTeX:', err); + }, + }); + + const hasMath = contentRef.current.querySelector('math.tml-display'); + const htmlChanged = + contentRef.current.innerHTML !== originalContentRefHTML; + if (hasMath && htmlChanged) { + setMathRendered(true); + } else { + showToast(t`Unable to format math`); + setMathRendered(false); + onRevert(); // Revert because DOM modified by cleanDOMForTemml + } + } catch (e) { + console.error('Failed to LaTeX:', e); + } + } + }, + [mathRendered], + ); + + return ( +
+ {t`Math expressions found.`}{' '} + +
+ ); +}; + +export default MathBlock; diff --git a/src/components/media-first-container.jsx b/src/components/media-first-container.jsx new file mode 100644 index 00000000..95bf4eea --- /dev/null +++ b/src/components/media-first-container.jsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +import isRTL from '../utils/is-rtl'; + +import Icon from './icon'; +import Media from './media'; + +function MediaFirstContainer(props) { + const { mediaAttachments, language, postID, instance } = props; + const moreThanOne = mediaAttachments.length > 1; + + const carouselRef = useRef(); + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + let handleScroll = () => { + const { clientWidth, scrollLeft } = carouselRef.current; + const index = Math.round(Math.abs(scrollLeft) / clientWidth); + setCurrentIndex(index); + }; + if (carouselRef.current) { + carouselRef.current.addEventListener('scroll', handleScroll, { + passive: true, + }); + } + return () => { + if (carouselRef.current) { + carouselRef.current.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + return ( + <> +
+ + {moreThanOne && ( + + )} +
+ {moreThanOne && ( + + )} + + ); +} + +export default MediaFirstContainer; diff --git a/src/components/multiple-media-figure.jsx b/src/components/multiple-media-figure.jsx new file mode 100644 index 00000000..36b93765 --- /dev/null +++ b/src/components/multiple-media-figure.jsx @@ -0,0 +1,14 @@ +function MultipleMediaFigure(props) { + const { enabled, children, lang, captionChildren } = props; + if (!enabled || !captionChildren) return children; + return ( +
+ {children} +
+ {captionChildren} +
+
+ ); +} + +export default MultipleMediaFigure; diff --git a/src/components/post-content.jsx b/src/components/post-content.jsx new file mode 100644 index 00000000..d5e12b0f --- /dev/null +++ b/src/components/post-content.jsx @@ -0,0 +1,64 @@ +import { useLayoutEffect, useRef } from 'preact/hooks'; + +import enhanceContent from '../utils/enhance-content'; +import handleContentLinks from '../utils/handle-content-links'; + +const HTTP_REGEX = /^http/i; + +const PostContent = + /*memo(*/ + ({ post, instance, previewMode }) => { + const { content, emojis, language, mentions, url } = post; + + const divRef = useRef(); + useLayoutEffect(() => { + if (!divRef.current) return; + const dom = enhanceContent(content, { + emojis, + returnDOM: true, + }); + // Remove target="_blank" from links + for (const a of dom.querySelectorAll('a.u-url[target="_blank"]')) { + if (!HTTP_REGEX.test(a.innerText.trim())) { + a.removeAttribute('target'); + } + } + divRef.current.replaceChildren(dom.cloneNode(true)); + }, [content, emojis?.length]); + + 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 + ? ` +

📊:

+ ` + : '') + + (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 = `${description}`; + } 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 && ( + + )} +
+

+ Embed post +

+
+
+

+ HTML Code +

+ + + {!!mediaAttachments?.length && ( +
+

+ Media attachments: +

+ +
+ )} + {!!accountEmojis?.length && ( +
+

+ Account Emojis: +

+
    + {accountEmojis.map((emoji) => { + return ( +
  • + + + {`:${emoji.shortcode}:`} + {' '} + :{emoji.shortcode}: ( + + URL + + ) + {emoji.staticUrl ? ( + <> + {' '} + ( + + static URL + + ) + + ) : null} +
  • + ); + })} +
+
+ )} + {!!emojis?.length && ( +
+

+ Emojis: +

+
    + {emojis.map((emoji) => { + return ( +
  • + + + {`:${emoji.shortcode}:`} + {' '} + :{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 = /