this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor components from status

+1559 -1493
+26
src/components/byline.jsx
··· 1 + import { Trans } from '@lingui/react/macro'; 2 + 3 + import Icon from './icon'; 4 + import NameText from './name-text'; 5 + 6 + function Byline({ authors, hidden, children }) { 7 + if (hidden) return children; 8 + if (!authors?.[0]?.account?.id) return children; 9 + const author = authors[0].account; 10 + 11 + return ( 12 + <div class="card-byline"> 13 + {children} 14 + <div class="card-byline-author"> 15 + <Icon icon="link" size="s" />{' '} 16 + <small> 17 + <Trans comment="More from [Author]"> 18 + More from <NameText account={author} showAvatar /> 19 + </Trans> 20 + </small> 21 + </div> 22 + </div> 23 + ); 24 + } 25 + 26 + export default Byline;
+164
src/components/math-block.jsx
··· 1 + import 'temml/dist/Temml-Local.css'; 2 + 3 + import { useLingui } from '@lingui/react/macro'; 4 + import { useCallback, useState } from 'preact/hooks'; 5 + 6 + import showToast from '../utils/show-toast'; 7 + 8 + import Icon from './icon'; 9 + 10 + // Follow https://mathstodon.xyz/about 11 + // > You can use LaTeX in toots here! Use \( and \) for inline, and \[ and \] for display mode. 12 + const DELIMITERS_PATTERNS = [ 13 + // '\\$\\$[\\s\\S]*?\\$\\$', // $$...$$ 14 + '\\\\\\[[\\s\\S]*?\\\\\\]', // \[...\] 15 + '\\\\\\([\\s\\S]*?\\\\\\)', // \(...\) 16 + // '\\\\begin\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}[\\s\\S]*?\\\\end\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}', // AMS environments 17 + // '\\\\(?:ref|eqref)\\{[^}]*\\}', // \ref{...}, \eqref{...} 18 + ]; 19 + const DELIMITERS_REGEX = new RegExp(DELIMITERS_PATTERNS.join('|'), 'g'); 20 + 21 + function cleanDOMForTemml(dom) { 22 + // Define start and end delimiter patterns 23 + const START_DELIMITERS = ['\\\\\\[', '\\\\\\(']; // \[ and \( 24 + const startRegex = new RegExp(`(${START_DELIMITERS.join('|')})`); 25 + 26 + // Walk through all text nodes 27 + const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT); 28 + const textNodes = []; 29 + let node; 30 + while ((node = walker.nextNode())) { 31 + textNodes.push(node); 32 + } 33 + 34 + for (const textNode of textNodes) { 35 + const text = textNode.textContent; 36 + const startMatch = text.match(startRegex); 37 + 38 + if (!startMatch) continue; // No start delimiter in this text node 39 + 40 + // Find the matching end delimiter 41 + const startDelimiter = startMatch[0]; 42 + const endDelimiter = startDelimiter === '\\[' ? '\\]' : '\\)'; 43 + 44 + // Collect nodes from start delimiter until end delimiter 45 + const nodesToCombine = [textNode]; 46 + let currentNode = textNode; 47 + let foundEnd = false; 48 + let combinedText = text; 49 + 50 + // Check if end delimiter is in the same text node 51 + if (text.includes(endDelimiter)) { 52 + foundEnd = true; 53 + } else { 54 + // Look through sibling nodes 55 + while (currentNode.nextSibling && !foundEnd) { 56 + const nextSibling = currentNode.nextSibling; 57 + 58 + if (nextSibling.nodeType === Node.TEXT_NODE) { 59 + nodesToCombine.push(nextSibling); 60 + combinedText += nextSibling.textContent; 61 + if (nextSibling.textContent.includes(endDelimiter)) { 62 + foundEnd = true; 63 + } 64 + } else if ( 65 + nextSibling.nodeType === Node.ELEMENT_NODE && 66 + nextSibling.tagName === 'BR' 67 + ) { 68 + nodesToCombine.push(nextSibling); 69 + combinedText += '\n'; 70 + } else { 71 + // Found a non-BR element, stop and don't process 72 + break; 73 + } 74 + 75 + currentNode = nextSibling; 76 + } 77 + } 78 + 79 + // Only process if we found the end delimiter and have nodes to combine 80 + if (foundEnd && nodesToCombine.length > 1) { 81 + // Replace the first text node with combined text 82 + textNode.textContent = combinedText; 83 + 84 + // Remove the other nodes 85 + for (let i = 1; i < nodesToCombine.length; i++) { 86 + nodesToCombine[i].remove(); 87 + } 88 + } 89 + } 90 + } 91 + 92 + const MathBlock = ({ content, contentRef, onRevert }) => { 93 + DELIMITERS_REGEX.lastIndex = 0; // Reset index to prevent g trap 94 + const hasLatexContent = DELIMITERS_REGEX.test(content); 95 + 96 + if (!hasLatexContent) return null; 97 + 98 + const { t } = useLingui(); 99 + const [mathRendered, setMathRendered] = useState(false); 100 + const toggleMathRendering = useCallback( 101 + async (e) => { 102 + e.preventDefault(); 103 + e.stopPropagation(); 104 + if (mathRendered) { 105 + // Revert to original content by refreshing PostContent 106 + setMathRendered(false); 107 + onRevert(); 108 + } else { 109 + // Render math 110 + try { 111 + // This needs global because the codebase inside temml is calling a function from global.temml 🤦‍♂️ 112 + const temml = 113 + window.temml || (window.temml = (await import('temml'))?.default); 114 + 115 + cleanDOMForTemml(contentRef.current); 116 + const originalContentRefHTML = contentRef.current.innerHTML; 117 + temml.renderMathInElement(contentRef.current, { 118 + fences: '(', // This should sync with DELIMITERS_REGEX 119 + annotate: true, 120 + throwOnError: true, 121 + errorCallback: (err) => { 122 + console.warn('Failed to render LaTeX:', err); 123 + }, 124 + }); 125 + 126 + const hasMath = contentRef.current.querySelector('math.tml-display'); 127 + const htmlChanged = 128 + contentRef.current.innerHTML !== originalContentRefHTML; 129 + if (hasMath && htmlChanged) { 130 + setMathRendered(true); 131 + } else { 132 + showToast(t`Unable to format math`); 133 + setMathRendered(false); 134 + onRevert(); // Revert because DOM modified by cleanDOMForTemml 135 + } 136 + } catch (e) { 137 + console.error('Failed to LaTeX:', e); 138 + } 139 + } 140 + }, 141 + [mathRendered], 142 + ); 143 + 144 + return ( 145 + <div class="math-block"> 146 + <Icon icon="formula" size="s" /> <span>{t`Math expressions found.`}</span>{' '} 147 + <button type="button" class="light small" onClick={toggleMathRendering}> 148 + {mathRendered 149 + ? t({ 150 + comment: 151 + 'Action to switch from rendered math back to raw (LaTeX) markup', 152 + message: 'Show markup', 153 + }) 154 + : t({ 155 + comment: 156 + 'Action to render math expressions from raw (LaTeX) markup', 157 + message: 'Format math', 158 + })} 159 + </button> 160 + </div> 161 + ); 162 + }; 163 + 164 + export default MathBlock;
+116
src/components/media-first-container.jsx
··· 1 + import { useEffect, useRef, useState } from 'preact/hooks'; 2 + 3 + import isRTL from '../utils/is-rtl'; 4 + 5 + import Icon from './icon'; 6 + import Media from './media'; 7 + 8 + function MediaFirstContainer(props) { 9 + const { mediaAttachments, language, postID, instance } = props; 10 + const moreThanOne = mediaAttachments.length > 1; 11 + 12 + const carouselRef = useRef(); 13 + const [currentIndex, setCurrentIndex] = useState(0); 14 + 15 + useEffect(() => { 16 + let handleScroll = () => { 17 + const { clientWidth, scrollLeft } = carouselRef.current; 18 + const index = Math.round(Math.abs(scrollLeft) / clientWidth); 19 + setCurrentIndex(index); 20 + }; 21 + if (carouselRef.current) { 22 + carouselRef.current.addEventListener('scroll', handleScroll, { 23 + passive: true, 24 + }); 25 + } 26 + return () => { 27 + if (carouselRef.current) { 28 + carouselRef.current.removeEventListener('scroll', handleScroll); 29 + } 30 + }; 31 + }, []); 32 + 33 + return ( 34 + <> 35 + <div class="media-first-container"> 36 + <div class="media-first-carousel" ref={carouselRef}> 37 + {mediaAttachments.map((media, i) => ( 38 + <div class="media-first-item" key={media.id}> 39 + <Media 40 + media={media} 41 + lang={language} 42 + to={`/${instance}/s/${postID}?media=${i + 1}`} 43 + /> 44 + </div> 45 + ))} 46 + </div> 47 + {moreThanOne && ( 48 + <div class="media-carousel-controls"> 49 + <div class="carousel-indexer"> 50 + {currentIndex + 1}/{mediaAttachments.length} 51 + </div> 52 + <label class="media-carousel-button"> 53 + <button 54 + type="button" 55 + class="carousel-button" 56 + hidden={currentIndex === 0} 57 + onClick={(e) => { 58 + e.preventDefault(); 59 + e.stopPropagation(); 60 + carouselRef.current.focus(); 61 + carouselRef.current.scrollTo({ 62 + left: 63 + carouselRef.current.clientWidth * 64 + (currentIndex - 1) * 65 + (isRTL() ? -1 : 1), 66 + behavior: 'smooth', 67 + }); 68 + }} 69 + > 70 + <Icon icon="arrow-left" /> 71 + </button> 72 + </label> 73 + <label class="media-carousel-button"> 74 + <button 75 + type="button" 76 + class="carousel-button" 77 + hidden={currentIndex === mediaAttachments.length - 1} 78 + onClick={(e) => { 79 + e.preventDefault(); 80 + e.stopPropagation(); 81 + carouselRef.current.focus(); 82 + carouselRef.current.scrollTo({ 83 + left: 84 + carouselRef.current.clientWidth * 85 + (currentIndex + 1) * 86 + (isRTL() ? -1 : 1), 87 + behavior: 'smooth', 88 + }); 89 + }} 90 + > 91 + <Icon icon="arrow-right" /> 92 + </button> 93 + </label> 94 + </div> 95 + )} 96 + </div> 97 + {moreThanOne && ( 98 + <div 99 + class="media-carousel-dots" 100 + style={{ 101 + '--dots-count': mediaAttachments.length, 102 + }} 103 + > 104 + {mediaAttachments.map((media, i) => ( 105 + <span 106 + key={media.id} 107 + class={`carousel-dot ${i === currentIndex ? 'active' : ''}`} 108 + /> 109 + ))} 110 + </div> 111 + )} 112 + </> 113 + ); 114 + } 115 + 116 + export default MediaFirstContainer;
+14
src/components/multiple-media-figure.jsx
··· 1 + function MultipleMediaFigure(props) { 2 + const { enabled, children, lang, captionChildren } = props; 3 + if (!enabled || !captionChildren) return children; 4 + return ( 5 + <figure class="media-figure-multiple"> 6 + {children} 7 + <figcaption lang={lang} dir="auto"> 8 + {captionChildren} 9 + </figcaption> 10 + </figure> 11 + ); 12 + } 13 + 14 + export default MultipleMediaFigure;
+64
src/components/post-content.jsx
··· 1 + import { useLayoutEffect, useRef } from 'preact/hooks'; 2 + 3 + import enhanceContent from '../utils/enhance-content'; 4 + import handleContentLinks from '../utils/handle-content-links'; 5 + 6 + const HTTP_REGEX = /^http/i; 7 + 8 + const PostContent = 9 + /*memo(*/ 10 + ({ post, instance, previewMode }) => { 11 + const { content, emojis, language, mentions, url } = post; 12 + 13 + const divRef = useRef(); 14 + useLayoutEffect(() => { 15 + if (!divRef.current) return; 16 + const dom = enhanceContent(content, { 17 + emojis, 18 + returnDOM: true, 19 + }); 20 + // Remove target="_blank" from links 21 + for (const a of dom.querySelectorAll('a.u-url[target="_blank"]')) { 22 + if (!HTTP_REGEX.test(a.innerText.trim())) { 23 + a.removeAttribute('target'); 24 + } 25 + } 26 + divRef.current.replaceChildren(dom.cloneNode(true)); 27 + }, [content, emojis?.length]); 28 + 29 + return ( 30 + <div 31 + ref={divRef} 32 + lang={language} 33 + dir="auto" 34 + class="inner-content" 35 + onClick={handleContentLinks({ 36 + mentions, 37 + instance, 38 + previewMode, 39 + statusURL: url, 40 + })} 41 + // dangerouslySetInnerHTML={{ 42 + // __html: enhanceContent(content, { 43 + // emojis, 44 + // postEnhanceDOM: (dom) => { 45 + // // Remove target="_blank" from links 46 + // dom.querySelectorAll('a.u-url[target="_blank"]').forEach((a) => { 47 + // if (!/http/i.test(a.innerText.trim())) { 48 + // a.removeAttribute('target'); 49 + // } 50 + // }); 51 + // }, 52 + // }), 53 + // }} 54 + /> 55 + ); 56 + }; /*, 57 + (oldProps, newProps) => { 58 + const { post: oldPost } = oldProps; 59 + const { post: newPost } = newProps; 60 + return oldPost.content === newPost.content; 61 + }, 62 + );*/ 63 + 64 + export default PostContent;
+394
src/components/post-embed-modal.jsx
··· 1 + import { Trans, useLingui } from '@lingui/react/macro'; 2 + import prettify from 'html-prettify'; 3 + 4 + import emojifyText from '../utils/emojify-text'; 5 + import showToast from '../utils/show-toast'; 6 + import states, { statusKey } from '../utils/states'; 7 + 8 + import Icon from './icon'; 9 + 10 + function generateHTMLCode(post, instance, level = 0) { 11 + const { 12 + account: { 13 + url: accountURL, 14 + displayName, 15 + acct, 16 + username, 17 + emojis: accountEmojis, 18 + bot, 19 + group, 20 + }, 21 + id, 22 + poll, 23 + spoilerText, 24 + language, 25 + editedAt, 26 + createdAt, 27 + content, 28 + mediaAttachments, 29 + url, 30 + emojis, 31 + } = post; 32 + 33 + const sKey = statusKey(id, instance); 34 + const quotes = states.statusQuotes[sKey] || []; 35 + const uniqueQuotes = quotes.filter( 36 + (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i, 37 + ); 38 + const quoteStatusesHTML = 39 + uniqueQuotes.length && level <= 2 40 + ? uniqueQuotes 41 + .map((quote) => { 42 + const { id, instance } = quote; 43 + const sKey = statusKey(id, instance); 44 + const s = states.statuses[sKey]; 45 + if (s) { 46 + return generateHTMLCode(s, instance, ++level); 47 + } 48 + }) 49 + .join('') 50 + : ''; 51 + 52 + const createdAtDate = new Date(createdAt); 53 + // const editedAtDate = editedAt && new Date(editedAt); 54 + 55 + const contentHTML = 56 + emojifyText(content, emojis) + 57 + '\n' + 58 + quoteStatusesHTML + 59 + '\n' + 60 + (poll?.options?.length 61 + ? ` 62 + <p>📊:</p> 63 + <ul> 64 + ${poll.options 65 + .map( 66 + (option) => ` 67 + <li> 68 + ${option.title} 69 + ${option.votesCount >= 0 ? ` (${option.votesCount})` : ''} 70 + </li> 71 + `, 72 + ) 73 + .join('')} 74 + </ul>` 75 + : '') + 76 + (mediaAttachments.length > 0 77 + ? '\n' + 78 + mediaAttachments 79 + .map((media) => { 80 + const { 81 + description, 82 + meta, 83 + previewRemoteUrl, 84 + previewUrl, 85 + remoteUrl, 86 + url, 87 + type, 88 + } = media; 89 + const { original = {}, small } = meta || {}; 90 + const width = small?.width || original?.width; 91 + const height = small?.height || original?.height; 92 + 93 + // Prefer remote over original 94 + const sourceMediaURL = remoteUrl || url; 95 + const previewMediaURL = previewRemoteUrl || previewUrl; 96 + const mediaURL = previewMediaURL || sourceMediaURL; 97 + 98 + const sourceMediaURLObj = sourceMediaURL 99 + ? URL.parse(sourceMediaURL) 100 + : null; 101 + const isVideoMaybe = 102 + type === 'unknown' && 103 + sourceMediaURLObj && 104 + /\.(mp4|m4r|m4v|mov|webm)$/i.test(sourceMediaURLObj.pathname); 105 + const isAudioMaybe = 106 + type === 'unknown' && 107 + sourceMediaURLObj && 108 + /\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(sourceMediaURLObj.pathname); 109 + const isImage = 110 + type === 'image' || 111 + (type === 'unknown' && 112 + previewMediaURL && 113 + !isVideoMaybe && 114 + !isAudioMaybe); 115 + const isVideo = type === 'gifv' || type === 'video' || isVideoMaybe; 116 + const isAudio = type === 'audio' || isAudioMaybe; 117 + 118 + let mediaHTML = ''; 119 + if (isImage) { 120 + mediaHTML = `<img src="${mediaURL}" width="${width}" height="${height}" alt="${description}" loading="lazy" />`; 121 + } else if (isVideo) { 122 + mediaHTML = ` 123 + <video src="${sourceMediaURL}" width="${width}" height="${height}" controls preload="auto" poster="${previewMediaURL}" loading="lazy"></video> 124 + ${description ? `<figcaption>${description}</figcaption>` : ''} 125 + `; 126 + } else if (isAudio) { 127 + mediaHTML = ` 128 + <audio src="${sourceMediaURL}" controls preload="auto"></audio> 129 + ${description ? `<figcaption>${description}</figcaption>` : ''} 130 + `; 131 + } else { 132 + mediaHTML = ` 133 + <a href="${sourceMediaURL}">📄 ${ 134 + description || sourceMediaURL 135 + }</a> 136 + `; 137 + } 138 + 139 + return `<figure>${mediaHTML}</figure>`; 140 + }) 141 + .join('\n') 142 + : ''); 143 + 144 + const htmlCode = ` 145 + <blockquote lang="${language}" cite="${url}" data-source="fediverse"> 146 + ${ 147 + spoilerText 148 + ? ` 149 + <details> 150 + <summary>${spoilerText}</summary> 151 + ${contentHTML} 152 + </details> 153 + ` 154 + : contentHTML 155 + } 156 + <footer> 157 + — ${emojifyText( 158 + displayName, 159 + accountEmojis, 160 + )} (@${acct}) ${!!createdAt ? `<a href="${url}"><time datetime="${createdAtDate.toISOString()}">${createdAtDate.toLocaleString()}</time></a>` : ''} 161 + </footer> 162 + </blockquote> 163 + `; 164 + 165 + return prettify(htmlCode); 166 + } 167 + 168 + function PostEmbedModal({ post, instance, onClose }) { 169 + const { t } = useLingui(); 170 + const { 171 + account: { 172 + url: accountURL, 173 + displayName, 174 + username, 175 + emojis: accountEmojis, 176 + bot, 177 + group, 178 + }, 179 + id, 180 + poll, 181 + spoilerText, 182 + language, 183 + editedAt, 184 + createdAt, 185 + content, 186 + mediaAttachments, 187 + url, 188 + emojis, 189 + } = post; 190 + 191 + const htmlCode = generateHTMLCode(post, instance); 192 + return ( 193 + <div id="embed-post" class="sheet"> 194 + {!!onClose && ( 195 + <button type="button" class="sheet-close" onClick={onClose}> 196 + <Icon icon="x" alt={t`Close`} /> 197 + </button> 198 + )} 199 + <header> 200 + <h2> 201 + <Trans>Embed post</Trans> 202 + </h2> 203 + </header> 204 + <main tabIndex="-1"> 205 + <h3> 206 + <Trans>HTML Code</Trans> 207 + </h3> 208 + <textarea 209 + class="embed-code" 210 + readonly 211 + onClick={(e) => { 212 + e.target.select(); 213 + }} 214 + dir="auto" 215 + > 216 + {htmlCode} 217 + </textarea> 218 + <button 219 + type="button" 220 + onClick={() => { 221 + try { 222 + navigator.clipboard.writeText(htmlCode); 223 + showToast(t`HTML code copied`); 224 + } catch (e) { 225 + console.error(e); 226 + showToast(t`Unable to copy HTML code`); 227 + } 228 + }} 229 + > 230 + <Icon icon="clipboard" />{' '} 231 + <span> 232 + <Trans>Copy</Trans> 233 + </span> 234 + </button> 235 + {!!mediaAttachments?.length && ( 236 + <section> 237 + <p> 238 + <Trans>Media attachments:</Trans> 239 + </p> 240 + <ol class="links-list"> 241 + {mediaAttachments.map((media) => { 242 + return ( 243 + <li key={media.id}> 244 + <a 245 + href={media.remoteUrl || media.url} 246 + target="_blank" 247 + download 248 + > 249 + {media.remoteUrl || media.url} 250 + </a> 251 + </li> 252 + ); 253 + })} 254 + </ol> 255 + </section> 256 + )} 257 + {!!accountEmojis?.length && ( 258 + <section> 259 + <p> 260 + <Trans>Account Emojis:</Trans> 261 + </p> 262 + <ul> 263 + {accountEmojis.map((emoji) => { 264 + return ( 265 + <li key={emoji.shortcode}> 266 + <picture> 267 + <source 268 + srcset={emoji.staticUrl} 269 + media="(prefers-reduced-motion: reduce)" 270 + ></source> 271 + <img 272 + class="shortcode-emoji emoji" 273 + src={emoji.url} 274 + alt={`:${emoji.shortcode}:`} 275 + width="16" 276 + height="16" 277 + loading="lazy" 278 + decoding="async" 279 + /> 280 + </picture>{' '} 281 + <code>:{emoji.shortcode}:</code> ( 282 + <a href={emoji.url} target="_blank" download> 283 + URL 284 + </a> 285 + ) 286 + {emoji.staticUrl ? ( 287 + <> 288 + {' '} 289 + ( 290 + <a href={emoji.staticUrl} target="_blank" download> 291 + <Trans>static URL</Trans> 292 + </a> 293 + ) 294 + </> 295 + ) : null} 296 + </li> 297 + ); 298 + })} 299 + </ul> 300 + </section> 301 + )} 302 + {!!emojis?.length && ( 303 + <section> 304 + <p> 305 + <Trans>Emojis:</Trans> 306 + </p> 307 + <ul> 308 + {emojis.map((emoji) => { 309 + return ( 310 + <li key={emoji.shortcode}> 311 + <picture> 312 + <source 313 + srcset={emoji.staticUrl} 314 + media="(prefers-reduced-motion: reduce)" 315 + ></source> 316 + <img 317 + class="shortcode-emoji emoji" 318 + src={emoji.url} 319 + alt={`:${emoji.shortcode}:`} 320 + width="16" 321 + height="16" 322 + loading="lazy" 323 + decoding="async" 324 + /> 325 + </picture>{' '} 326 + <code>:{emoji.shortcode}:</code> ( 327 + <a href={emoji.url} target="_blank" download> 328 + URL 329 + </a> 330 + ) 331 + {emoji.staticUrl ? ( 332 + <> 333 + {' '} 334 + ( 335 + <a href={emoji.staticUrl} target="_blank" download> 336 + <Trans>static URL</Trans> 337 + </a> 338 + ) 339 + </> 340 + ) : null} 341 + </li> 342 + ); 343 + })} 344 + </ul> 345 + </section> 346 + )} 347 + <section> 348 + <small> 349 + <p> 350 + <Trans>Notes:</Trans> 351 + </p> 352 + <ul> 353 + <li> 354 + <Trans> 355 + This is static, unstyled and scriptless. You may need to apply 356 + your own styles and edit as needed. 357 + </Trans> 358 + </li> 359 + <li> 360 + <Trans> 361 + Polls are not interactive, becomes a list with vote counts. 362 + </Trans> 363 + </li> 364 + <li> 365 + <Trans> 366 + Media attachments can be images, videos, audios or any file 367 + types. 368 + </Trans> 369 + </li> 370 + <li> 371 + <Trans>Post could be edited or deleted later.</Trans> 372 + </li> 373 + </ul> 374 + </small> 375 + </section> 376 + <h3> 377 + <Trans>Preview</Trans> 378 + </h3> 379 + <output 380 + class="embed-preview" 381 + dangerouslySetInnerHTML={{ __html: htmlCode }} 382 + dir="auto" 383 + /> 384 + <p> 385 + <small> 386 + <Trans>Note: This preview is lightly styled.</Trans> 387 + </small> 388 + </p> 389 + </main> 390 + </div> 391 + ); 392 + } 393 + 394 + export default PostEmbedModal;
+68
src/components/status-button.jsx
··· 1 + import { forwardRef } from 'preact/compat'; 2 + import { useEffect, useState } from 'preact/hooks'; 3 + 4 + import shortenNumber from '../utils/shorten-number'; 5 + 6 + import Icon from './icon'; 7 + 8 + const StatusButton = forwardRef((props, ref) => { 9 + let { 10 + checked, 11 + count, 12 + class: className, 13 + title, 14 + alt, 15 + size, 16 + icon, 17 + iconSize = 'l', 18 + onClick, 19 + ...otherProps 20 + } = props; 21 + if (typeof title === 'string') { 22 + title = [title, title]; 23 + } 24 + if (typeof alt === 'string') { 25 + alt = [alt, alt]; 26 + } 27 + 28 + const [buttonTitle, setButtonTitle] = useState(title[0] || ''); 29 + const [iconAlt, setIconAlt] = useState(alt[0] || ''); 30 + 31 + useEffect(() => { 32 + if (checked) { 33 + setButtonTitle(title[1] || ''); 34 + setIconAlt(alt[1] || ''); 35 + } else { 36 + setButtonTitle(title[0] || ''); 37 + setIconAlt(alt[0] || ''); 38 + } 39 + }, [checked, title, alt]); 40 + 41 + return ( 42 + <button 43 + ref={ref} 44 + type="button" 45 + title={buttonTitle} 46 + class={`plain ${size ? 'small' : ''} ${className} ${ 47 + checked ? 'checked' : '' 48 + }`} 49 + onClick={(e) => { 50 + if (!onClick) return; 51 + e.preventDefault(); 52 + e.stopPropagation(); 53 + onClick(e); 54 + }} 55 + {...otherProps} 56 + > 57 + <Icon icon={icon} size={iconSize} alt={iconAlt} /> 58 + {!!count && ( 59 + <> 60 + {' '} 61 + <small title={count}>{shortenNumber(count)}</small> 62 + </> 63 + )} 64 + </button> 65 + ); 66 + }); 67 + 68 + export default StatusButton;
+290
src/components/status-card.jsx
··· 1 + import '@justinribeiro/lite-youtube'; 2 + 3 + import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; 4 + import { useCallback, useEffect, useState } from 'preact/hooks'; 5 + import { useSnapshot } from 'valtio'; 6 + 7 + import getDomain from '../utils/get-domain'; 8 + import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; 9 + import states from '../utils/states'; 10 + import unfurlMastodonLink from '../utils/unfurl-link'; 11 + 12 + import Byline from './byline'; 13 + import Icon from './icon'; 14 + import RelativeTime from './relative-time'; 15 + 16 + // "Post": Quote post + card link preview combo 17 + // Assume all links from these domains are "posts" 18 + // Mastodon links are "posts" too but they are converted to real quote posts and there's too many domains to check 19 + // This is just "Progressive Enhancement" 20 + function isCardPost(domain) { 21 + return [ 22 + 'x.com', 23 + 'twitter.com', 24 + 'threads.net', 25 + 'bsky.app', 26 + 'bsky.brid.gy', 27 + 'fed.brid.gy', 28 + ].includes(domain); 29 + } 30 + 31 + function StatusCard({ card, selfReferential, selfAuthor, instance }) { 32 + const snapStates = useSnapshot(states); 33 + const { 34 + blurhash, 35 + title, 36 + description, 37 + html, 38 + providerName, 39 + providerUrl, 40 + authorName, 41 + authorUrl, 42 + width, 43 + height, 44 + image, 45 + imageDescription, 46 + url, 47 + type, 48 + embedUrl, 49 + language, 50 + publishedAt, 51 + authors, 52 + } = card; 53 + 54 + /* type 55 + link = Link OEmbed 56 + photo = Photo OEmbed 57 + video = Video OEmbed 58 + rich = iframe OEmbed. Not currently accepted, so won't show up in practice. 59 + */ 60 + 61 + const hasText = title || providerName || authorName; 62 + const isLandscape = width / height >= 1.2; 63 + const size = isLandscape ? 'large' : ''; 64 + 65 + const [cardStatusURL, setCardStatusURL] = useState(null); 66 + // const [cardStatusID, setCardStatusID] = useState(null); 67 + useEffect(() => { 68 + if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) { 69 + unfurlMastodonLink(instance, url).then((result) => { 70 + if (!result) return; 71 + const { id, url } = result; 72 + setCardStatusURL('#' + url); 73 + 74 + // NOTE: This is for quote post 75 + // (async () => { 76 + // const { masto } = api({ instance }); 77 + // const status = await masto.v1.statuses.$select(id).fetch(); 78 + // saveStatus(status, instance); 79 + // setCardStatusID(id); 80 + // })(); 81 + }); 82 + } 83 + }, [hasText, image, selfReferential]); 84 + 85 + // if (cardStatusID) { 86 + // return ( 87 + // <Status statusID={cardStatusID} instance={instance} size="s" readOnly /> 88 + // ); 89 + // } 90 + 91 + if (snapStates.unfurledLinks[url]) return null; 92 + 93 + const hasIframeHTML = /<iframe/i.test(html); 94 + const handleClick = useCallback( 95 + (e) => { 96 + if (hasIframeHTML) { 97 + e.preventDefault(); 98 + states.showEmbedModal = { 99 + html, 100 + url: url || embedUrl, 101 + width, 102 + height, 103 + }; 104 + } 105 + }, 106 + [hasIframeHTML], 107 + ); 108 + 109 + const [blurhashImage, setBlurhashImage] = useState(null); 110 + if (hasText && (image || (type === 'photo' && blurhash))) { 111 + const domain = getDomain(url); 112 + const rgbAverageColor = 113 + image && blurhash ? getBlurHashAverageColor(blurhash) : null; 114 + if (!image) { 115 + const w = 44; 116 + const h = 44; 117 + const blurhashPixels = decodeBlurHash(blurhash, w, h); 118 + const canvas = window.OffscreenCanvas 119 + ? new OffscreenCanvas(1, 1) 120 + : document.createElement('canvas'); 121 + canvas.width = w; 122 + canvas.height = h; 123 + const ctx = canvas.getContext('2d'); 124 + ctx.imageSmoothingEnabled = false; 125 + const imageData = ctx.createImageData(w, h); 126 + imageData.data.set(blurhashPixels); 127 + ctx.putImageData(imageData, 0, 0); 128 + try { 129 + if (window.OffscreenCanvas) { 130 + canvas.convertToBlob().then((blob) => { 131 + setBlurhashImage(URL.createObjectURL(blob)); 132 + }); 133 + } else { 134 + setBlurhashImage(canvas.toDataURL()); 135 + } 136 + } catch (e) { 137 + // Silently fail 138 + console.error(e); 139 + } 140 + } 141 + 142 + const isPost = isCardPost(domain); 143 + 144 + return ( 145 + <Byline hidden={!!selfAuthor} authors={authors}> 146 + <a 147 + href={cardStatusURL || url} 148 + target={cardStatusURL ? null : '_blank'} 149 + rel="nofollow noopener" 150 + class={`card link ${isPost ? 'card-post' : ''} ${ 151 + blurhashImage ? '' : size 152 + }`} 153 + style={{ 154 + '--average-color': 155 + rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 156 + }} 157 + onClick={handleClick} 158 + > 159 + <div class="card-image"> 160 + <img 161 + src={image || blurhashImage} 162 + width={width} 163 + height={height} 164 + loading="lazy" 165 + decoding="async" 166 + fetchPriority="low" 167 + alt={imageDescription || ''} 168 + onError={(e) => { 169 + try { 170 + e.target.style.display = 'none'; 171 + } catch (e) {} 172 + }} 173 + style={{ 174 + '--anim-duration': 175 + width && 176 + height && 177 + `${Math.min( 178 + Math.max(Math.max(width, height) / 100, 5), 179 + 120, 180 + )}s`, 181 + }} 182 + /> 183 + </div> 184 + <div class="meta-container" lang={language}> 185 + <p class="meta domain"> 186 + <span class="domain">{domain}</span>{' '} 187 + {!!publishedAt && <>&middot; </>} 188 + {!!publishedAt && ( 189 + <> 190 + <RelativeTime datetime={publishedAt} format="micro" /> 191 + </> 192 + )} 193 + </p> 194 + <p class="title" dir="auto" title={title}> 195 + {title} 196 + </p> 197 + <p class="meta" dir="auto" title={description}> 198 + {description || 199 + (!!publishedAt && ( 200 + <RelativeTime datetime={publishedAt} format="micro" /> 201 + ))} 202 + </p> 203 + </div> 204 + </a> 205 + </Byline> 206 + ); 207 + } else if (type === 'photo') { 208 + return ( 209 + <a 210 + href={url} 211 + target="_blank" 212 + rel="nofollow noopener" 213 + class="card photo" 214 + onClick={handleClick} 215 + > 216 + <img 217 + src={embedUrl} 218 + width={width} 219 + height={height} 220 + alt={title || description} 221 + loading="lazy" 222 + style={{ 223 + height: 'auto', 224 + aspectRatio: `${width}/${height}`, 225 + }} 226 + /> 227 + </a> 228 + ); 229 + } else { 230 + if (type === 'video') { 231 + if (/youtube/i.test(providerName)) { 232 + // Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID] 233 + const videoID = url.match(/watch\?v=([^&]+)/)?.[1]; 234 + if (videoID) { 235 + return ( 236 + <a class="card video" onClick={handleClick}> 237 + <lite-youtube videoid={videoID} nocookie autoPause></lite-youtube> 238 + </a> 239 + ); 240 + } 241 + } 242 + // return ( 243 + // <div 244 + // class="card video" 245 + // style={{ 246 + // aspectRatio: `${width}/${height}`, 247 + // }} 248 + // dangerouslySetInnerHTML={{ __html: html }} 249 + // /> 250 + // ); 251 + } 252 + if (hasText && !image) { 253 + const domain = getDomain(url); 254 + const isPost = isCardPost(domain); 255 + return ( 256 + <a 257 + href={cardStatusURL || url} 258 + target={cardStatusURL ? null : '_blank'} 259 + rel="nofollow noopener" 260 + class={`card link ${isPost ? 'card-post' : ''} no-image`} 261 + lang={language} 262 + dir="auto" 263 + onClick={handleClick} 264 + > 265 + <div class="meta-container"> 266 + <p class="meta domain"> 267 + <span class="domain"> 268 + <Icon icon="link" size="s" /> <span>{domain}</span> 269 + </span>{' '} 270 + {!!publishedAt && <>&middot; </>} 271 + {!!publishedAt && ( 272 + <> 273 + <RelativeTime datetime={publishedAt} format="micro" /> 274 + </> 275 + )} 276 + </p> 277 + <p class="title" title={title}> 278 + {title} 279 + </p> 280 + <p class="meta" title={description || providerName || authorName}> 281 + {description || providerName || authorName} 282 + </p> 283 + </div> 284 + </a> 285 + ); 286 + } 287 + } 288 + } 289 + 290 + export default StatusCard;
+82
src/components/status-compact.jsx
··· 1 + import { Trans } from '@lingui/react/macro'; 2 + import { useContext } from 'preact/hooks'; 3 + import { useSnapshot } from 'valtio'; 4 + 5 + import FilterContext from '../utils/filter-context'; 6 + import { isFiltered } from '../utils/filters'; 7 + import states, { getStatus, statusKey } from '../utils/states'; 8 + import statusPeek from '../utils/status-peek'; 9 + import { getCurrentAccID } from '../utils/store-utils'; 10 + 11 + import Avatar from './avatar'; 12 + 13 + function StatusCompact({ sKey }) { 14 + const snapStates = useSnapshot(states); 15 + const statusReply = snapStates.statusReply[sKey]; 16 + if (!statusReply) return null; 17 + 18 + const { id, instance } = statusReply; 19 + const status = getStatus(id, instance); 20 + if (!status) return null; 21 + 22 + const { 23 + account: { id: accountId }, 24 + sensitive, 25 + spoilerText, 26 + account: { avatar, avatarStatic, bot } = {}, 27 + visibility, 28 + content, 29 + language, 30 + filtered, 31 + } = status; 32 + if (sensitive || spoilerText) return null; 33 + if (!content) return null; 34 + 35 + const srKey = statusKey(id, instance); 36 + const statusPeekText = statusPeek(status); 37 + 38 + const currentAccount = getCurrentAccID(); 39 + const isSelf = currentAccount && currentAccount === accountId; 40 + 41 + const filterContext = useContext(FilterContext); 42 + let filterInfo = !isSelf && isFiltered(filtered, filterContext); 43 + 44 + // This is fine. Images are converted to emojis so they are 45 + // in a way, already "obscured" 46 + if (filterInfo?.action === 'blur') filterInfo = null; 47 + 48 + if (filterInfo?.action === 'hide') return null; 49 + 50 + const filterTitleStr = filterInfo?.titlesStr || ''; 51 + 52 + return ( 53 + <article 54 + class={`status compact-reply shazam ${ 55 + visibility === 'direct' ? 'visibility-direct' : '' 56 + }`} 57 + tabindex="-1" 58 + data-state-post-id={srKey} 59 + > 60 + <Avatar url={avatarStatic || avatar} squircle={bot} /> 61 + <div 62 + class="content-compact" 63 + title={statusPeekText} 64 + lang={language} 65 + dir="auto" 66 + > 67 + {filterInfo ? ( 68 + <b class="status-filtered-badge badge-meta" title={filterTitleStr}> 69 + <span> 70 + <Trans>Filtered</Trans> 71 + </span> 72 + <span>{filterTitleStr}</span> 73 + </b> 74 + ) : ( 75 + <span>{statusPeekText}</span> 76 + )} 77 + </div> 78 + </article> 79 + ); 80 + } 81 + 82 + export default StatusCompact;
+111 -1274
src/components/status.jsx
··· 1 1 import './status.css'; 2 - import 'temml/dist/Temml-Local.css'; 3 - 4 - import '@justinribeiro/lite-youtube'; 5 2 6 3 import { msg, plural } from '@lingui/core/macro'; 7 4 import { Trans, useLingui } from '@lingui/react/macro'; 8 - import { 9 - ControlledMenu, 10 - Menu, 11 - MenuDivider, 12 - MenuHeader, 13 - MenuItem, 14 - } from '@szhsin/react-menu'; 15 - import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; 5 + import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu'; 16 6 import { shallowEqual } from 'fast-equals'; 17 - import prettify from 'html-prettify'; 18 7 import pThrottle from 'p-throttle'; 19 8 import { Fragment } from 'preact'; 20 - import { forwardRef, memo } from 'preact/compat'; 9 + import { memo } from 'preact/compat'; 21 10 import { 22 11 useCallback, 23 12 useContext, 24 13 useEffect, 25 - useLayoutEffect, 26 14 useMemo, 27 15 useReducer, 28 16 useRef, ··· 30 18 } from 'preact/hooks'; 31 19 import punycode from 'punycode/'; 32 20 import { useHotkeys } from 'react-hotkeys-hook'; 33 - // import { detectAll } from 'tinyld/light'; 34 21 import { useLongPress } from 'use-long-press'; 35 22 import { useSnapshot } from 'valtio'; 36 23 37 - import CustomEmoji from '../components/custom-emoji'; 38 - import EmojiText from '../components/emoji-text'; 39 - import LazyShazam from '../components/lazy-shazam'; 40 - import Loader from '../components/loader'; 41 - import MenuConfirm from '../components/menu-confirm'; 42 - import Menu2 from '../components/menu2'; 43 - import Modal from '../components/modal'; 44 - import NameText from '../components/name-text'; 45 - import Poll from '../components/poll'; 46 24 import { api, getPreferences } from '../utils/api'; 47 25 import { langDetector } from '../utils/browser-translator'; 48 - import emojifyText from '../utils/emojify-text'; 49 - import enhanceContent from '../utils/enhance-content'; 50 26 import FilterContext from '../utils/filter-context'; 51 27 import { isFiltered } from '../utils/filters'; 52 - import getDomain from '../utils/get-domain'; 53 28 import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 54 29 import getHTMLText from '../utils/getHTMLText'; 55 - import handleContentLinks from '../utils/handle-content-links'; 56 30 import htmlContentLength from '../utils/html-content-length'; 57 - import isRTL from '../utils/is-rtl'; 58 - import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; 59 31 import localeMatch from '../utils/locale-match'; 60 - import mem from '../utils/mem'; 61 32 import niceDateTime from '../utils/nice-date-time'; 62 33 import openCompose from '../utils/open-compose'; 63 34 import pmem from '../utils/pmem'; ··· 69 40 import { speak, supportsTTS } from '../utils/speech'; 70 41 import states, { getStatus, saveStatus, statusKey } from '../utils/states'; 71 42 import statusPeek from '../utils/status-peek'; 72 - import store from '../utils/store'; 73 - import { getCurrentAccountID } from '../utils/store-utils'; 43 + import { getCurrentAccID, getCurrentAccountID } from '../utils/store-utils'; 74 44 import supports from '../utils/supports'; 75 - import unfurlMastodonLink from '../utils/unfurl-link'; 76 45 import useTruncated from '../utils/useTruncated'; 77 46 import visibilityIconsMap from '../utils/visibility-icons-map'; 78 47 79 48 import Avatar from './avatar'; 49 + import CustomEmoji from './custom-emoji'; 50 + import EmojiText from './emoji-text'; 80 51 import Icon from './icon'; 52 + import LazyShazam from './lazy-shazam'; 81 53 import Link from './link'; 54 + import Loader from './loader'; 55 + import MathBlock from './math-block'; 82 56 import Media, { isMediaCaptionLong } from './media'; 57 + import MediaFirstContainer from './media-first-container'; 58 + import MenuConfirm from './menu-confirm'; 83 59 import MenuLink from './menu-link'; 60 + import Menu2 from './menu2'; 61 + import Modal from './modal'; 62 + import MultipleMediaFigure from './multiple-media-figure'; 63 + import NameText from './name-text'; 64 + import Poll from './poll'; 65 + import PostContent from './post-content'; 66 + import PostEmbedModal from './post-embed-modal'; 84 67 import RelativeTime from './relative-time'; 68 + import StatusButton from './status-button'; 69 + import StatusCard from './status-card'; 70 + import StatusCompact from './status-compact'; 85 71 import ThreadBadge from './thread-badge'; 86 72 import TranslationBlock from './translation-block'; 87 73 ··· 224 210 }); 225 211 } 226 212 227 - const HTTP_REGEX = /^http/i; 228 - 229 - // Follow https://mathstodon.xyz/about 230 - // > You can use LaTeX in toots here! Use \( and \) for inline, and \[ and \] for display mode. 231 - const DELIMITERS_PATTERNS = [ 232 - // '\\$\\$[\\s\\S]*?\\$\\$', // $$...$$ 233 - '\\\\\\[[\\s\\S]*?\\\\\\]', // \[...\] 234 - '\\\\\\([\\s\\S]*?\\\\\\)', // \(...\) 235 - // '\\\\begin\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}[\\s\\S]*?\\\\end\\{(?:equation\\*?|align\\*?|alignat\\*?|gather\\*?|CD)\\}', // AMS environments 236 - // '\\\\(?:ref|eqref)\\{[^}]*\\}', // \ref{...}, \eqref{...} 237 - ]; 238 - const DELIMITERS_REGEX = new RegExp(DELIMITERS_PATTERNS.join('|'), 'g'); 239 - 240 - function cleanDOMForTemml(dom) { 241 - // Define start and end delimiter patterns 242 - const START_DELIMITERS = ['\\\\\\[', '\\\\\\(']; // \[ and \( 243 - const startRegex = new RegExp(`(${START_DELIMITERS.join('|')})`); 244 - 245 - // Walk through all text nodes 246 - const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT); 247 - const textNodes = []; 248 - let node; 249 - while ((node = walker.nextNode())) { 250 - textNodes.push(node); 251 - } 252 - 253 - for (const textNode of textNodes) { 254 - const text = textNode.textContent; 255 - const startMatch = text.match(startRegex); 256 - 257 - if (!startMatch) continue; // No start delimiter in this text node 258 - 259 - // Find the matching end delimiter 260 - const startDelimiter = startMatch[0]; 261 - const endDelimiter = startDelimiter === '\\[' ? '\\]' : '\\)'; 262 - 263 - // Collect nodes from start delimiter until end delimiter 264 - const nodesToCombine = [textNode]; 265 - let currentNode = textNode; 266 - let foundEnd = false; 267 - let combinedText = text; 268 - 269 - // Check if end delimiter is in the same text node 270 - if (text.includes(endDelimiter)) { 271 - foundEnd = true; 272 - } else { 273 - // Look through sibling nodes 274 - while (currentNode.nextSibling && !foundEnd) { 275 - const nextSibling = currentNode.nextSibling; 276 - 277 - if (nextSibling.nodeType === Node.TEXT_NODE) { 278 - nodesToCombine.push(nextSibling); 279 - combinedText += nextSibling.textContent; 280 - if (nextSibling.textContent.includes(endDelimiter)) { 281 - foundEnd = true; 282 - } 283 - } else if ( 284 - nextSibling.nodeType === Node.ELEMENT_NODE && 285 - nextSibling.tagName === 'BR' 286 - ) { 287 - nodesToCombine.push(nextSibling); 288 - combinedText += '\n'; 289 - } else { 290 - // Found a non-BR element, stop and don't process 291 - break; 292 - } 293 - 294 - currentNode = nextSibling; 295 - } 296 - } 297 - 298 - // Only process if we found the end delimiter and have nodes to combine 299 - if (foundEnd && nodesToCombine.length > 1) { 300 - // Replace the first text node with combined text 301 - textNode.textContent = combinedText; 302 - 303 - // Remove the other nodes 304 - for (let i = 1; i < nodesToCombine.length; i++) { 305 - nodesToCombine[i].remove(); 306 - } 307 - } 308 - } 309 - } 310 - 311 - const MathBlock = ({ content, contentRef, onRevert }) => { 312 - DELIMITERS_REGEX.lastIndex = 0; // Reset index to prevent g trap 313 - const hasLatexContent = DELIMITERS_REGEX.test(content); 314 - 315 - if (!hasLatexContent) return null; 316 - 317 - const { t } = useLingui(); 318 - const [mathRendered, setMathRendered] = useState(false); 319 - const toggleMathRendering = useCallback( 320 - async (e) => { 321 - e.preventDefault(); 322 - e.stopPropagation(); 323 - if (mathRendered) { 324 - // Revert to original content by refreshing PostContent 325 - setMathRendered(false); 326 - onRevert(); 327 - } else { 328 - // Render math 329 - try { 330 - // This needs global because the codebase inside temml is calling a function from global.temml 🤦‍♂️ 331 - const temml = 332 - window.temml || (window.temml = (await import('temml'))?.default); 333 - 334 - cleanDOMForTemml(contentRef.current); 335 - const originalContentRefHTML = contentRef.current.innerHTML; 336 - temml.renderMathInElement(contentRef.current, { 337 - fences: '(', // This should sync with DELIMITERS_REGEX 338 - annotate: true, 339 - throwOnError: true, 340 - errorCallback: (err) => { 341 - console.warn('Failed to render LaTeX:', err); 342 - }, 343 - }); 344 - 345 - const hasMath = contentRef.current.querySelector('math.tml-display'); 346 - const htmlChanged = 347 - contentRef.current.innerHTML !== originalContentRefHTML; 348 - if (hasMath && htmlChanged) { 349 - setMathRendered(true); 350 - } else { 351 - showToast(t`Unable to format math`); 352 - setMathRendered(false); 353 - onRevert(); // Revert because DOM modified by cleanDOMForTemml 354 - } 355 - } catch (e) { 356 - console.error('Failed to LaTeX:', e); 357 - } 358 - } 359 - }, 360 - [mathRendered], 361 - ); 362 - 363 - return ( 364 - <div class="math-block"> 365 - <Icon icon="formula" size="s" /> <span>{t`Math expressions found.`}</span>{' '} 366 - <button type="button" class="light small" onClick={toggleMathRendering}> 367 - {mathRendered 368 - ? t({ 369 - comment: 370 - 'Action to switch from rendered math back to raw (LaTeX) markup', 371 - message: 'Show markup', 372 - }) 373 - : t({ 374 - comment: 375 - 'Action to render math expressions from raw (LaTeX) markup', 376 - message: 'Format math', 377 - })} 378 - </button> 379 - </div> 380 - ); 381 - }; 382 - 383 - const PostContent = 384 - /*memo(*/ 385 - ({ post, instance, previewMode }) => { 386 - const { content, emojis, language, mentions, url } = post; 387 - 388 - const divRef = useRef(); 389 - useLayoutEffect(() => { 390 - if (!divRef.current) return; 391 - const dom = enhanceContent(content, { 392 - emojis, 393 - returnDOM: true, 394 - }); 395 - // Remove target="_blank" from links 396 - for (const a of dom.querySelectorAll('a.u-url[target="_blank"]')) { 397 - if (!HTTP_REGEX.test(a.innerText.trim())) { 398 - a.removeAttribute('target'); 399 - } 400 - } 401 - divRef.current.replaceChildren(dom.cloneNode(true)); 402 - }, [content, emojis?.length]); 403 - 404 - return ( 405 - <div 406 - ref={divRef} 407 - lang={language} 408 - dir="auto" 409 - class="inner-content" 410 - onClick={handleContentLinks({ 411 - mentions, 412 - instance, 413 - previewMode, 414 - statusURL: url, 415 - })} 416 - // dangerouslySetInnerHTML={{ 417 - // __html: enhanceContent(content, { 418 - // emojis, 419 - // postEnhanceDOM: (dom) => { 420 - // // Remove target="_blank" from links 421 - // dom.querySelectorAll('a.u-url[target="_blank"]').forEach((a) => { 422 - // if (!/http/i.test(a.innerText.trim())) { 423 - // a.removeAttribute('target'); 424 - // } 425 - // }); 426 - // }, 427 - // }), 428 - // }} 429 - /> 430 - ); 431 - }; /*, 432 - (oldProps, newProps) => { 433 - const { post: oldPost } = oldProps; 434 - const { post: newPost } = newProps; 435 - return oldPost.content === newPost.content; 436 - }, 437 - );*/ 438 - 439 213 const SIZE_CLASS = { 440 214 s: 'small', 441 215 m: 'medium', ··· 510 284 } 511 285 return different; 512 286 }; 513 - 514 - const getCurrentAccID = mem( 515 - () => { 516 - return getCurrentAccountID(); 517 - }, 518 - { 519 - maxAge: 60 * 1000, // 1 minute 520 - }, 521 - ); 522 287 523 288 function Status({ 524 289 statusID, ··· 2544 2309 !poll && 2545 2310 !mediaAttachments.length && 2546 2311 !snapStates.statusQuotes[sKey] && ( 2547 - <Card 2312 + <StatusCard 2548 2313 card={card} 2549 2314 selfReferential={ 2550 2315 card?.url === status.url || card?.url === status.uri ··· 2819 2584 } 2820 2585 }} 2821 2586 > 2822 - <EmbedModal 2587 + <PostEmbedModal 2823 2588 post={status} 2824 2589 instance={instance} 2825 2590 onClose={() => { ··· 2833 2598 ); 2834 2599 } 2835 2600 2836 - function MultipleMediaFigure(props) { 2837 - const { enabled, children, lang, captionChildren } = props; 2838 - if (!enabled || !captionChildren) return children; 2839 - return ( 2840 - <figure class="media-figure-multiple"> 2841 - {children} 2842 - <figcaption lang={lang} dir="auto"> 2843 - {captionChildren} 2844 - </figcaption> 2845 - </figure> 2846 - ); 2847 - } 2848 - 2849 - function MediaFirstContainer(props) { 2850 - const { mediaAttachments, language, postID, instance } = props; 2851 - const moreThanOne = mediaAttachments.length > 1; 2852 - 2853 - const carouselRef = useRef(); 2854 - const [currentIndex, setCurrentIndex] = useState(0); 2855 - 2856 - useEffect(() => { 2857 - let handleScroll = () => { 2858 - const { clientWidth, scrollLeft } = carouselRef.current; 2859 - const index = Math.round(Math.abs(scrollLeft) / clientWidth); 2860 - setCurrentIndex(index); 2861 - }; 2862 - if (carouselRef.current) { 2863 - carouselRef.current.addEventListener('scroll', handleScroll, { 2864 - passive: true, 2865 - }); 2866 - } 2867 - return () => { 2868 - if (carouselRef.current) { 2869 - carouselRef.current.removeEventListener('scroll', handleScroll); 2870 - } 2871 - }; 2872 - }, []); 2873 - 2601 + function nicePostURL(url) { 2602 + if (!url) return; 2603 + const urlObj = URL.parse(url); 2604 + if (!urlObj) return; 2605 + const { host, pathname } = urlObj; 2606 + const path = pathname.replace(/\/$/, ''); 2607 + // split only first slash 2608 + const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; 2874 2609 return ( 2875 2610 <> 2876 - <div class="media-first-container"> 2877 - <div class="media-first-carousel" ref={carouselRef}> 2878 - {mediaAttachments.map((media, i) => ( 2879 - <div class="media-first-item" key={media.id}> 2880 - <Media 2881 - media={media} 2882 - lang={language} 2883 - to={`/${instance}/s/${postID}?media=${i + 1}`} 2884 - /> 2885 - </div> 2886 - ))} 2887 - </div> 2888 - {moreThanOne && ( 2889 - <div class="media-carousel-controls"> 2890 - <div class="carousel-indexer"> 2891 - {currentIndex + 1}/{mediaAttachments.length} 2892 - </div> 2893 - <label class="media-carousel-button"> 2894 - <button 2895 - type="button" 2896 - class="carousel-button" 2897 - hidden={currentIndex === 0} 2898 - onClick={(e) => { 2899 - e.preventDefault(); 2900 - e.stopPropagation(); 2901 - carouselRef.current.focus(); 2902 - carouselRef.current.scrollTo({ 2903 - left: 2904 - carouselRef.current.clientWidth * 2905 - (currentIndex - 1) * 2906 - (isRTL() ? -1 : 1), 2907 - behavior: 'smooth', 2908 - }); 2909 - }} 2910 - > 2911 - <Icon icon="arrow-left" /> 2912 - </button> 2913 - </label> 2914 - <label class="media-carousel-button"> 2915 - <button 2916 - type="button" 2917 - class="carousel-button" 2918 - hidden={currentIndex === mediaAttachments.length - 1} 2919 - onClick={(e) => { 2920 - e.preventDefault(); 2921 - e.stopPropagation(); 2922 - carouselRef.current.focus(); 2923 - carouselRef.current.scrollTo({ 2924 - left: 2925 - carouselRef.current.clientWidth * 2926 - (currentIndex + 1) * 2927 - (isRTL() ? -1 : 1), 2928 - behavior: 'smooth', 2929 - }); 2930 - }} 2931 - > 2932 - <Icon icon="arrow-right" /> 2933 - </button> 2934 - </label> 2935 - </div> 2936 - )} 2937 - </div> 2938 - {moreThanOne && ( 2939 - <div 2940 - class="media-carousel-dots" 2941 - style={{ 2942 - '--dots-count': mediaAttachments.length, 2943 - }} 2944 - > 2945 - {mediaAttachments.map((media, i) => ( 2946 - <span 2947 - key={media.id} 2948 - class={`carousel-dot ${i === currentIndex ? 'active' : ''}`} 2949 - /> 2950 - ))} 2951 - </div> 2611 + {punycode.toUnicode(host)} 2612 + {username ? ( 2613 + <> 2614 + /{username} 2615 + <wbr /> 2616 + <span class="more-insignificant">/{restPath}</span> 2617 + </> 2618 + ) : ( 2619 + <span class="more-insignificant">{path}</span> 2952 2620 )} 2953 2621 </> 2954 2622 ); 2955 2623 } 2956 2624 2957 - // "Post": Quote post + card link preview combo 2958 - // Assume all links from these domains are "posts" 2959 - // Mastodon links are "posts" too but they are converted to real quote posts and there's too many domains to check 2960 - // This is just "Progressive Enhancement" 2961 - function isCardPost(domain) { 2962 - return [ 2963 - 'x.com', 2964 - 'twitter.com', 2965 - 'threads.net', 2966 - 'bsky.app', 2967 - 'bsky.brid.gy', 2968 - 'fed.brid.gy', 2969 - ].includes(domain); 2970 - } 2625 + const handledUnfulfilledStates = [ 2626 + 'deleted', 2627 + 'unauthorized', 2628 + 'pending', 2629 + 'rejected', 2630 + 'revoked', 2631 + ]; 2632 + const unfulfilledText = { 2633 + filterHidden: msg`Post hidden by your filters`, 2634 + pending: msg`Post pending`, 2635 + deleted: msg`Post unavailable`, 2636 + unauthorized: msg`Post unavailable`, 2637 + rejected: msg`Post unavailable`, 2638 + revoked: msg`Post unavailable`, 2639 + }; 2971 2640 2972 - function Byline({ authors, hidden, children }) { 2973 - if (hidden) return children; 2974 - if (!authors?.[0]?.account?.id) return children; 2975 - const author = authors[0].account; 2976 - 2977 - return ( 2978 - <div class="card-byline"> 2979 - {children} 2980 - <div class="card-byline-author"> 2981 - <Icon icon="link" size="s" />{' '} 2982 - <small> 2983 - <Trans comment="More from [Author]"> 2984 - More from <NameText account={author} showAvatar /> 2985 - </Trans> 2986 - </small> 2987 - </div> 2988 - </div> 2989 - ); 2990 - } 2991 - 2992 - function Card({ card, selfReferential, selfAuthor, instance }) { 2641 + const QuoteStatuses = memo(({ id, instance, level = 0 }) => { 2642 + if (!id || !instance) return; 2643 + const { _ } = useLingui(); 2993 2644 const snapStates = useSnapshot(states); 2994 - const { 2995 - blurhash, 2996 - title, 2997 - description, 2998 - html, 2999 - providerName, 3000 - providerUrl, 3001 - authorName, 3002 - authorUrl, 3003 - width, 3004 - height, 3005 - image, 3006 - imageDescription, 3007 - url, 3008 - type, 3009 - embedUrl, 3010 - language, 3011 - publishedAt, 3012 - authors, 3013 - } = card; 3014 - 3015 - /* type 3016 - link = Link OEmbed 3017 - photo = Photo OEmbed 3018 - video = Video OEmbed 3019 - rich = iframe OEmbed. Not currently accepted, so won’t show up in practice. 3020 - */ 2645 + const sKey = statusKey(id, instance); 2646 + const quotes = snapStates.statusQuotes[sKey]; 2647 + const uniqueQuotes = quotes?.filter( 2648 + (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i, 2649 + ); 3021 2650 3022 - const hasText = title || providerName || authorName; 3023 - const isLandscape = width / height >= 1.2; 3024 - const size = isLandscape ? 'large' : ''; 2651 + if (!uniqueQuotes?.length) return; 2652 + if (level > 2) return; 3025 2653 3026 - const [cardStatusURL, setCardStatusURL] = useState(null); 3027 - // const [cardStatusID, setCardStatusID] = useState(null); 3028 - useEffect(() => { 3029 - if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) { 3030 - unfurlMastodonLink(instance, url).then((result) => { 3031 - if (!result) return; 3032 - const { id, url } = result; 3033 - setCardStatusURL('#' + url); 2654 + const filterContext = useContext(FilterContext); 2655 + const currentAccount = getCurrentAccountID(); 3034 2656 3035 - // NOTE: This is for quote post 3036 - // (async () => { 3037 - // const { masto } = api({ instance }); 3038 - // const status = await masto.v1.statuses.$select(id).fetch(); 3039 - // saveStatus(status, instance); 3040 - // setCardStatusID(id); 3041 - // })(); 3042 - }); 3043 - } 3044 - }, [hasText, image, selfReferential]); 3045 - 3046 - // if (cardStatusID) { 3047 - // return ( 3048 - // <Status statusID={cardStatusID} instance={instance} size="s" readOnly /> 3049 - // ); 3050 - // } 2657 + return uniqueQuotes.map((q) => { 2658 + let unfulfilledState; 3051 2659 3052 - if (snapStates.unfurledLinks[url]) return null; 2660 + const quoteStatus = snapStates.statuses[statusKey(q.id, q.instance)]; 2661 + if (quoteStatus) { 2662 + const isSelf = 2663 + currentAccount && currentAccount === quoteStatus.account?.id; 2664 + const filterInfo = 2665 + !isSelf && isFiltered(quoteStatus.filtered, filterContext); 3053 2666 3054 - const hasIframeHTML = /<iframe/i.test(html); 3055 - const handleClick = useCallback( 3056 - (e) => { 3057 - if (hasIframeHTML) { 3058 - e.preventDefault(); 3059 - states.showEmbedModal = { 3060 - html, 3061 - url: url || embedUrl, 3062 - width, 3063 - height, 3064 - }; 2667 + if (filterInfo?.action === 'hide') { 2668 + unfulfilledState = 'filterHidden'; 3065 2669 } 3066 - }, 3067 - [hasIframeHTML], 3068 - ); 2670 + } 3069 2671 3070 - const [blurhashImage, setBlurhashImage] = useState(null); 3071 - if (hasText && (image || (type === 'photo' && blurhash))) { 3072 - const domain = getDomain(url); 3073 - const rgbAverageColor = 3074 - image && blurhash ? getBlurHashAverageColor(blurhash) : null; 3075 - if (!image) { 3076 - const w = 44; 3077 - const h = 44; 3078 - const blurhashPixels = decodeBlurHash(blurhash, w, h); 3079 - const canvas = window.OffscreenCanvas 3080 - ? new OffscreenCanvas(1, 1) 3081 - : document.createElement('canvas'); 3082 - canvas.width = w; 3083 - canvas.height = h; 3084 - const ctx = canvas.getContext('2d'); 3085 - ctx.imageSmoothingEnabled = false; 3086 - const imageData = ctx.createImageData(w, h); 3087 - imageData.data.set(blurhashPixels); 3088 - ctx.putImageData(imageData, 0, 0); 3089 - try { 3090 - if (window.OffscreenCanvas) { 3091 - canvas.convertToBlob().then((blob) => { 3092 - setBlurhashImage(URL.createObjectURL(blob)); 3093 - }); 3094 - } else { 3095 - setBlurhashImage(canvas.toDataURL()); 3096 - } 3097 - } catch (e) { 3098 - // Silently fail 3099 - console.error(e); 3100 - } 2672 + if (!unfulfilledState) { 2673 + unfulfilledState = handledUnfulfilledStates.find( 2674 + (state) => q.state === state, 2675 + ); 3101 2676 } 3102 2677 3103 - const isPost = isCardPost(domain); 2678 + if (unfulfilledState) { 2679 + return ( 2680 + <div 2681 + class={`status-card-unfulfilled ${ 2682 + unfulfilledState === 'filterHidden' ? 'status-card-ghost' : '' 2683 + }`} 2684 + > 2685 + <Icon icon="quote" /> 2686 + <i>{_(unfulfilledText[unfulfilledState])}</i> 2687 + </div> 2688 + ); 2689 + } 3104 2690 2691 + const Parent = q.native ? Fragment : LazyShazam; 3105 2692 return ( 3106 - <Byline hidden={!!selfAuthor} authors={authors}> 3107 - <a 3108 - href={cardStatusURL || url} 3109 - target={cardStatusURL ? null : '_blank'} 3110 - rel="nofollow noopener" 3111 - class={`card link ${isPost ? 'card-post' : ''} ${ 3112 - blurhashImage ? '' : size 3113 - }`} 3114 - style={{ 3115 - '--average-color': 3116 - rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 3117 - }} 3118 - onClick={handleClick} 2693 + <Parent id={q.instance + q.id} key={q.instance + q.id}> 2694 + <Link 2695 + key={q.instance + q.id} 2696 + to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`} 2697 + class={`status-card-link ${q.native ? 'quote-post-native' : ''}`} 2698 + data-read-more={_(readMoreText)} 3119 2699 > 3120 - <div class="card-image"> 3121 - <img 3122 - src={image || blurhashImage} 3123 - width={width} 3124 - height={height} 3125 - loading="lazy" 3126 - decoding="async" 3127 - fetchPriority="low" 3128 - alt={imageDescription || ''} 3129 - onError={(e) => { 3130 - try { 3131 - e.target.style.display = 'none'; 3132 - } catch (e) {} 3133 - }} 3134 - style={{ 3135 - '--anim-duration': 3136 - width && 3137 - height && 3138 - `${Math.min( 3139 - Math.max(Math.max(width, height) / 100, 5), 3140 - 120, 3141 - )}s`, 3142 - }} 3143 - /> 3144 - </div> 3145 - <div class="meta-container" lang={language}> 3146 - <p class="meta domain"> 3147 - <span class="domain">{domain}</span>{' '} 3148 - {!!publishedAt && <>&middot; </>} 3149 - {!!publishedAt && ( 3150 - <> 3151 - <RelativeTime datetime={publishedAt} format="micro" /> 3152 - </> 3153 - )} 3154 - </p> 3155 - <p class="title" dir="auto" title={title}> 3156 - {title} 3157 - </p> 3158 - <p class="meta" dir="auto" title={description}> 3159 - {description || 3160 - (!!publishedAt && ( 3161 - <RelativeTime datetime={publishedAt} format="micro" /> 3162 - ))} 3163 - </p> 3164 - </div> 3165 - </a> 3166 - </Byline> 2700 + <Status 2701 + statusID={q.id} 2702 + instance={q.instance} 2703 + size="s" 2704 + quoted={level + 1} 2705 + enableCommentHint 2706 + /> 2707 + </Link> 2708 + </Parent> 3167 2709 ); 3168 - } else if (type === 'photo') { 3169 - return ( 3170 - <a 3171 - href={url} 3172 - target="_blank" 3173 - rel="nofollow noopener" 3174 - class="card photo" 3175 - onClick={handleClick} 3176 - > 3177 - <img 3178 - src={embedUrl} 3179 - width={width} 3180 - height={height} 3181 - alt={title || description} 3182 - loading="lazy" 3183 - style={{ 3184 - height: 'auto', 3185 - aspectRatio: `${width}/${height}`, 3186 - }} 3187 - /> 3188 - </a> 3189 - ); 3190 - } else { 3191 - if (type === 'video') { 3192 - if (/youtube/i.test(providerName)) { 3193 - // Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID] 3194 - const videoID = url.match(/watch\?v=([^&]+)/)?.[1]; 3195 - if (videoID) { 3196 - return ( 3197 - <a class="card video" onClick={handleClick}> 3198 - <lite-youtube videoid={videoID} nocookie autoPause></lite-youtube> 3199 - </a> 3200 - ); 3201 - } 3202 - } 3203 - // return ( 3204 - // <div 3205 - // class="card video" 3206 - // style={{ 3207 - // aspectRatio: `${width}/${height}`, 3208 - // }} 3209 - // dangerouslySetInnerHTML={{ __html: html }} 3210 - // /> 3211 - // ); 3212 - } 3213 - if (hasText && !image) { 3214 - const domain = getDomain(url); 3215 - const isPost = isCardPost(domain); 3216 - return ( 3217 - <a 3218 - href={cardStatusURL || url} 3219 - target={cardStatusURL ? null : '_blank'} 3220 - rel="nofollow noopener" 3221 - class={`card link ${isPost ? 'card-post' : ''} no-image`} 3222 - lang={language} 3223 - dir="auto" 3224 - onClick={handleClick} 3225 - > 3226 - <div class="meta-container"> 3227 - <p class="meta domain"> 3228 - <span class="domain"> 3229 - <Icon icon="link" size="s" /> <span>{domain}</span> 3230 - </span>{' '} 3231 - {!!publishedAt && <>&middot; </>} 3232 - {!!publishedAt && ( 3233 - <> 3234 - <RelativeTime datetime={publishedAt} format="micro" /> 3235 - </> 3236 - )} 3237 - </p> 3238 - <p class="title" title={title}> 3239 - {title} 3240 - </p> 3241 - <p class="meta" title={description || providerName || authorName}> 3242 - {description || providerName || authorName} 3243 - </p> 3244 - </div> 3245 - </a> 3246 - ); 3247 - } 3248 - } 3249 - } 2710 + }); 2711 + }); 3250 2712 3251 2713 function EditedAtModal({ 3252 2714 statusID, ··· 3331 2793 ); 3332 2794 } 3333 2795 3334 - function generateHTMLCode(post, instance, level = 0) { 3335 - const { 3336 - account: { 3337 - url: accountURL, 3338 - displayName, 3339 - acct, 3340 - username, 3341 - emojis: accountEmojis, 3342 - bot, 3343 - group, 3344 - }, 3345 - id, 3346 - poll, 3347 - spoilerText, 3348 - language, 3349 - editedAt, 3350 - createdAt, 3351 - content, 3352 - mediaAttachments, 3353 - url, 3354 - emojis, 3355 - } = post; 3356 - 3357 - const sKey = statusKey(id, instance); 3358 - const quotes = states.statusQuotes[sKey] || []; 3359 - const uniqueQuotes = quotes.filter( 3360 - (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i, 3361 - ); 3362 - const quoteStatusesHTML = 3363 - uniqueQuotes.length && level <= 2 3364 - ? uniqueQuotes 3365 - .map((quote) => { 3366 - const { id, instance } = quote; 3367 - const sKey = statusKey(id, instance); 3368 - const s = states.statuses[sKey]; 3369 - if (s) { 3370 - return generateHTMLCode(s, instance, ++level); 3371 - } 3372 - }) 3373 - .join('') 3374 - : ''; 3375 - 3376 - const createdAtDate = new Date(createdAt); 3377 - // const editedAtDate = editedAt && new Date(editedAt); 3378 - 3379 - const contentHTML = 3380 - emojifyText(content, emojis) + 3381 - '\n' + 3382 - quoteStatusesHTML + 3383 - '\n' + 3384 - (poll?.options?.length 3385 - ? ` 3386 - <p>📊:</p> 3387 - <ul> 3388 - ${poll.options 3389 - .map( 3390 - (option) => ` 3391 - <li> 3392 - ${option.title} 3393 - ${option.votesCount >= 0 ? ` (${option.votesCount})` : ''} 3394 - </li> 3395 - `, 3396 - ) 3397 - .join('')} 3398 - </ul>` 3399 - : '') + 3400 - (mediaAttachments.length > 0 3401 - ? '\n' + 3402 - mediaAttachments 3403 - .map((media) => { 3404 - const { 3405 - description, 3406 - meta, 3407 - previewRemoteUrl, 3408 - previewUrl, 3409 - remoteUrl, 3410 - url, 3411 - type, 3412 - } = media; 3413 - const { original = {}, small } = meta || {}; 3414 - const width = small?.width || original?.width; 3415 - const height = small?.height || original?.height; 3416 - 3417 - // Prefer remote over original 3418 - const sourceMediaURL = remoteUrl || url; 3419 - const previewMediaURL = previewRemoteUrl || previewUrl; 3420 - const mediaURL = previewMediaURL || sourceMediaURL; 3421 - 3422 - const sourceMediaURLObj = sourceMediaURL 3423 - ? URL.parse(sourceMediaURL) 3424 - : null; 3425 - const isVideoMaybe = 3426 - type === 'unknown' && 3427 - sourceMediaURLObj && 3428 - /\.(mp4|m4r|m4v|mov|webm)$/i.test(sourceMediaURLObj.pathname); 3429 - const isAudioMaybe = 3430 - type === 'unknown' && 3431 - sourceMediaURLObj && 3432 - /\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(sourceMediaURLObj.pathname); 3433 - const isImage = 3434 - type === 'image' || 3435 - (type === 'unknown' && 3436 - previewMediaURL && 3437 - !isVideoMaybe && 3438 - !isAudioMaybe); 3439 - const isVideo = type === 'gifv' || type === 'video' || isVideoMaybe; 3440 - const isAudio = type === 'audio' || isAudioMaybe; 3441 - 3442 - let mediaHTML = ''; 3443 - if (isImage) { 3444 - mediaHTML = `<img src="${mediaURL}" width="${width}" height="${height}" alt="${description}" loading="lazy" />`; 3445 - } else if (isVideo) { 3446 - mediaHTML = ` 3447 - <video src="${sourceMediaURL}" width="${width}" height="${height}" controls preload="auto" poster="${previewMediaURL}" loading="lazy"></video> 3448 - ${description ? `<figcaption>${description}</figcaption>` : ''} 3449 - `; 3450 - } else if (isAudio) { 3451 - mediaHTML = ` 3452 - <audio src="${sourceMediaURL}" controls preload="auto"></audio> 3453 - ${description ? `<figcaption>${description}</figcaption>` : ''} 3454 - `; 3455 - } else { 3456 - mediaHTML = ` 3457 - <a href="${sourceMediaURL}">📄 ${ 3458 - description || sourceMediaURL 3459 - }</a> 3460 - `; 3461 - } 3462 - 3463 - return `<figure>${mediaHTML}</figure>`; 3464 - }) 3465 - .join('\n') 3466 - : ''); 3467 - 3468 - const htmlCode = ` 3469 - <blockquote lang="${language}" cite="${url}" data-source="fediverse"> 3470 - ${ 3471 - spoilerText 3472 - ? ` 3473 - <details> 3474 - <summary>${spoilerText}</summary> 3475 - ${contentHTML} 3476 - </details> 3477 - ` 3478 - : contentHTML 3479 - } 3480 - <footer> 3481 - — ${emojifyText( 3482 - displayName, 3483 - accountEmojis, 3484 - )} (@${acct}) ${!!createdAt ? `<a href="${url}"><time datetime="${createdAtDate.toISOString()}">${createdAtDate.toLocaleString()}</time></a>` : ''} 3485 - </footer> 3486 - </blockquote> 3487 - `; 3488 - 3489 - return prettify(htmlCode); 3490 - } 3491 - 3492 - function EmbedModal({ post, instance, onClose }) { 3493 - const { t } = useLingui(); 3494 - const { 3495 - account: { 3496 - url: accountURL, 3497 - displayName, 3498 - username, 3499 - emojis: accountEmojis, 3500 - bot, 3501 - group, 3502 - }, 3503 - id, 3504 - poll, 3505 - spoilerText, 3506 - language, 3507 - editedAt, 3508 - createdAt, 3509 - content, 3510 - mediaAttachments, 3511 - url, 3512 - emojis, 3513 - } = post; 3514 - 3515 - const htmlCode = generateHTMLCode(post, instance); 3516 - return ( 3517 - <div id="embed-post" class="sheet"> 3518 - {!!onClose && ( 3519 - <button type="button" class="sheet-close" onClick={onClose}> 3520 - <Icon icon="x" alt={t`Close`} /> 3521 - </button> 3522 - )} 3523 - <header> 3524 - <h2> 3525 - <Trans>Embed post</Trans> 3526 - </h2> 3527 - </header> 3528 - <main tabIndex="-1"> 3529 - <h3> 3530 - <Trans>HTML Code</Trans> 3531 - </h3> 3532 - <textarea 3533 - class="embed-code" 3534 - readonly 3535 - onClick={(e) => { 3536 - e.target.select(); 3537 - }} 3538 - dir="auto" 3539 - > 3540 - {htmlCode} 3541 - </textarea> 3542 - <button 3543 - type="button" 3544 - onClick={() => { 3545 - try { 3546 - navigator.clipboard.writeText(htmlCode); 3547 - showToast(t`HTML code copied`); 3548 - } catch (e) { 3549 - console.error(e); 3550 - showToast(t`Unable to copy HTML code`); 3551 - } 3552 - }} 3553 - > 3554 - <Icon icon="clipboard" />{' '} 3555 - <span> 3556 - <Trans>Copy</Trans> 3557 - </span> 3558 - </button> 3559 - {!!mediaAttachments?.length && ( 3560 - <section> 3561 - <p> 3562 - <Trans>Media attachments:</Trans> 3563 - </p> 3564 - <ol class="links-list"> 3565 - {mediaAttachments.map((media) => { 3566 - return ( 3567 - <li key={media.id}> 3568 - <a 3569 - href={media.remoteUrl || media.url} 3570 - target="_blank" 3571 - download 3572 - > 3573 - {media.remoteUrl || media.url} 3574 - </a> 3575 - </li> 3576 - ); 3577 - })} 3578 - </ol> 3579 - </section> 3580 - )} 3581 - {!!accountEmojis?.length && ( 3582 - <section> 3583 - <p> 3584 - <Trans>Account Emojis:</Trans> 3585 - </p> 3586 - <ul> 3587 - {accountEmojis.map((emoji) => { 3588 - return ( 3589 - <li key={emoji.shortcode}> 3590 - <picture> 3591 - <source 3592 - srcset={emoji.staticUrl} 3593 - media="(prefers-reduced-motion: reduce)" 3594 - ></source> 3595 - <img 3596 - class="shortcode-emoji emoji" 3597 - src={emoji.url} 3598 - alt={`:${emoji.shortcode}:`} 3599 - width="16" 3600 - height="16" 3601 - loading="lazy" 3602 - decoding="async" 3603 - /> 3604 - </picture>{' '} 3605 - <code>:{emoji.shortcode}:</code> ( 3606 - <a href={emoji.url} target="_blank" download> 3607 - URL 3608 - </a> 3609 - ) 3610 - {emoji.staticUrl ? ( 3611 - <> 3612 - {' '} 3613 - ( 3614 - <a href={emoji.staticUrl} target="_blank" download> 3615 - <Trans>static URL</Trans> 3616 - </a> 3617 - ) 3618 - </> 3619 - ) : null} 3620 - </li> 3621 - ); 3622 - })} 3623 - </ul> 3624 - </section> 3625 - )} 3626 - {!!emojis?.length && ( 3627 - <section> 3628 - <p> 3629 - <Trans>Emojis:</Trans> 3630 - </p> 3631 - <ul> 3632 - {emojis.map((emoji) => { 3633 - return ( 3634 - <li key={emoji.shortcode}> 3635 - <picture> 3636 - <source 3637 - srcset={emoji.staticUrl} 3638 - media="(prefers-reduced-motion: reduce)" 3639 - ></source> 3640 - <img 3641 - class="shortcode-emoji emoji" 3642 - src={emoji.url} 3643 - alt={`:${emoji.shortcode}:`} 3644 - width="16" 3645 - height="16" 3646 - loading="lazy" 3647 - decoding="async" 3648 - /> 3649 - </picture>{' '} 3650 - <code>:{emoji.shortcode}:</code> ( 3651 - <a href={emoji.url} target="_blank" download> 3652 - URL 3653 - </a> 3654 - ) 3655 - {emoji.staticUrl ? ( 3656 - <> 3657 - {' '} 3658 - ( 3659 - <a href={emoji.staticUrl} target="_blank" download> 3660 - <Trans>static URL</Trans> 3661 - </a> 3662 - ) 3663 - </> 3664 - ) : null} 3665 - </li> 3666 - ); 3667 - })} 3668 - </ul> 3669 - </section> 3670 - )} 3671 - <section> 3672 - <small> 3673 - <p> 3674 - <Trans>Notes:</Trans> 3675 - </p> 3676 - <ul> 3677 - <li> 3678 - <Trans> 3679 - This is static, unstyled and scriptless. You may need to apply 3680 - your own styles and edit as needed. 3681 - </Trans> 3682 - </li> 3683 - <li> 3684 - <Trans> 3685 - Polls are not interactive, becomes a list with vote counts. 3686 - </Trans> 3687 - </li> 3688 - <li> 3689 - <Trans> 3690 - Media attachments can be images, videos, audios or any file 3691 - types. 3692 - </Trans> 3693 - </li> 3694 - <li> 3695 - <Trans>Post could be edited or deleted later.</Trans> 3696 - </li> 3697 - </ul> 3698 - </small> 3699 - </section> 3700 - <h3> 3701 - <Trans>Preview</Trans> 3702 - </h3> 3703 - <output 3704 - class="embed-preview" 3705 - dangerouslySetInnerHTML={{ __html: htmlCode }} 3706 - dir="auto" 3707 - /> 3708 - <p> 3709 - <small> 3710 - <Trans>Note: This preview is lightly styled.</Trans> 3711 - </small> 3712 - </p> 3713 - </main> 3714 - </div> 3715 - ); 3716 - } 3717 - 3718 - const StatusButton = forwardRef((props, ref) => { 3719 - let { 3720 - checked, 3721 - count, 3722 - class: className, 3723 - title, 3724 - alt, 3725 - size, 3726 - icon, 3727 - iconSize = 'l', 3728 - onClick, 3729 - ...otherProps 3730 - } = props; 3731 - if (typeof title === 'string') { 3732 - title = [title, title]; 3733 - } 3734 - if (typeof alt === 'string') { 3735 - alt = [alt, alt]; 3736 - } 3737 - 3738 - const [buttonTitle, setButtonTitle] = useState(title[0] || ''); 3739 - const [iconAlt, setIconAlt] = useState(alt[0] || ''); 3740 - 3741 - useEffect(() => { 3742 - if (checked) { 3743 - setButtonTitle(title[1] || ''); 3744 - setIconAlt(alt[1] || ''); 3745 - } else { 3746 - setButtonTitle(title[0] || ''); 3747 - setIconAlt(alt[0] || ''); 3748 - } 3749 - }, [checked, title, alt]); 3750 - 3751 - return ( 3752 - <button 3753 - ref={ref} 3754 - type="button" 3755 - title={buttonTitle} 3756 - class={`plain ${size ? 'small' : ''} ${className} ${ 3757 - checked ? 'checked' : '' 3758 - }`} 3759 - onClick={(e) => { 3760 - if (!onClick) return; 3761 - e.preventDefault(); 3762 - e.stopPropagation(); 3763 - onClick(e); 3764 - }} 3765 - {...otherProps} 3766 - > 3767 - <Icon icon={icon} size={iconSize} alt={iconAlt} /> 3768 - {!!count && ( 3769 - <> 3770 - {' '} 3771 - <small title={count}>{shortenNumber(count)}</small> 3772 - </> 3773 - )} 3774 - </button> 3775 - ); 3776 - }); 3777 - 3778 - function nicePostURL(url) { 3779 - if (!url) return; 3780 - const urlObj = URL.parse(url); 3781 - if (!urlObj) return; 3782 - const { host, pathname } = urlObj; 3783 - const path = pathname.replace(/\/$/, ''); 3784 - // split only first slash 3785 - const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; 3786 - return ( 3787 - <> 3788 - {punycode.toUnicode(host)} 3789 - {username ? ( 3790 - <> 3791 - /{username} 3792 - <wbr /> 3793 - <span class="more-insignificant">/{restPath}</span> 3794 - </> 3795 - ) : ( 3796 - <span class="more-insignificant">{path}</span> 3797 - )} 3798 - </> 3799 - ); 3800 - } 3801 - 3802 - function StatusCompact({ sKey }) { 3803 - const snapStates = useSnapshot(states); 3804 - const statusReply = snapStates.statusReply[sKey]; 3805 - if (!statusReply) return null; 3806 - 3807 - const { id, instance } = statusReply; 3808 - const status = getStatus(id, instance); 3809 - if (!status) return null; 3810 - 3811 - const { 3812 - account: { id: accountId }, 3813 - sensitive, 3814 - spoilerText, 3815 - account: { avatar, avatarStatic, bot } = {}, 3816 - visibility, 3817 - content, 3818 - language, 3819 - filtered, 3820 - } = status; 3821 - if (sensitive || spoilerText) return null; 3822 - if (!content) return null; 3823 - 3824 - const srKey = statusKey(id, instance); 3825 - const statusPeekText = statusPeek(status); 3826 - 3827 - const currentAccount = getCurrentAccID(); 3828 - const isSelf = currentAccount && currentAccount === accountId; 3829 - 3830 - const filterContext = useContext(FilterContext); 3831 - let filterInfo = !isSelf && isFiltered(filtered, filterContext); 3832 - 3833 - // This is fine. Images are converted to emojis so they are 3834 - // in a way, already "obscured" 3835 - if (filterInfo?.action === 'blur') filterInfo = null; 3836 - 3837 - if (filterInfo?.action === 'hide') return null; 3838 - 3839 - const filterTitleStr = filterInfo?.titlesStr || ''; 3840 - 3841 - return ( 3842 - <article 3843 - class={`status compact-reply shazam ${ 3844 - visibility === 'direct' ? 'visibility-direct' : '' 3845 - }`} 3846 - tabindex="-1" 3847 - data-state-post-id={srKey} 3848 - > 3849 - <Avatar url={avatarStatic || avatar} squircle={bot} /> 3850 - <div 3851 - class="content-compact" 3852 - title={statusPeekText} 3853 - lang={language} 3854 - dir="auto" 3855 - > 3856 - {filterInfo ? ( 3857 - <b class="status-filtered-badge badge-meta" title={filterTitleStr}> 3858 - <span> 3859 - <Trans>Filtered</Trans> 3860 - </span> 3861 - <span>{filterTitleStr}</span> 3862 - </b> 3863 - ) : ( 3864 - <span>{statusPeekText}</span> 3865 - )} 3866 - </div> 3867 - </article> 3868 - ); 3869 - } 3870 - 3871 2796 function FilteredStatus({ 3872 2797 status, 3873 2798 filterInfo, ··· 4053 2978 </div> 4054 2979 ); 4055 2980 } 4056 - 4057 - const handledUnfulfilledStates = [ 4058 - 'deleted', 4059 - 'unauthorized', 4060 - 'pending', 4061 - 'rejected', 4062 - 'revoked', 4063 - ]; 4064 - const unfulfilledText = { 4065 - filterHidden: msg`Post hidden by your filters`, 4066 - pending: msg`Post pending`, 4067 - deleted: msg`Post unavailable`, 4068 - unauthorized: msg`Post unavailable`, 4069 - rejected: msg`Post unavailable`, 4070 - revoked: msg`Post unavailable`, 4071 - }; 4072 - 4073 - const QuoteStatuses = memo(({ id, instance, level = 0 }) => { 4074 - if (!id || !instance) return; 4075 - const { _ } = useLingui(); 4076 - const snapStates = useSnapshot(states); 4077 - const sKey = statusKey(id, instance); 4078 - const quotes = snapStates.statusQuotes[sKey]; 4079 - const uniqueQuotes = quotes?.filter( 4080 - (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i, 4081 - ); 4082 - 4083 - if (!uniqueQuotes?.length) return; 4084 - if (level > 2) return; 4085 - 4086 - const filterContext = useContext(FilterContext); 4087 - const currentAccount = getCurrentAccID(); 4088 - 4089 - return uniqueQuotes.map((q) => { 4090 - let unfulfilledState; 4091 - 4092 - const quoteStatus = snapStates.statuses[statusKey(q.id, q.instance)]; 4093 - if (quoteStatus) { 4094 - const isSelf = 4095 - currentAccount && currentAccount === quoteStatus.account?.id; 4096 - const filterInfo = 4097 - !isSelf && isFiltered(quoteStatus.filtered, filterContext); 4098 - 4099 - if (filterInfo?.action === 'hide') { 4100 - unfulfilledState = 'filterHidden'; 4101 - } 4102 - } 4103 - 4104 - if (!unfulfilledState) { 4105 - unfulfilledState = handledUnfulfilledStates.find( 4106 - (state) => q.state === state, 4107 - ); 4108 - } 4109 - 4110 - if (unfulfilledState) { 4111 - return ( 4112 - <div 4113 - class={`status-card-unfulfilled ${ 4114 - unfulfilledState === 'filterHidden' ? 'status-card-ghost' : '' 4115 - }`} 4116 - > 4117 - <Icon icon="quote" /> 4118 - <i>{_(unfulfilledText[unfulfilledState])}</i> 4119 - </div> 4120 - ); 4121 - } 4122 - 4123 - const Parent = q.native ? Fragment : LazyShazam; 4124 - return ( 4125 - <Parent id={q.instance + q.id} key={q.instance + q.id}> 4126 - <Link 4127 - key={q.instance + q.id} 4128 - to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`} 4129 - class={`status-card-link ${q.native ? 'quote-post-native' : ''}`} 4130 - data-read-more={_(readMoreText)} 4131 - > 4132 - <Status 4133 - statusID={q.id} 4134 - instance={q.instance} 4135 - size="s" 4136 - quoted={level + 1} 4137 - enableCommentHint 4138 - /> 4139 - </Link> 4140 - </Parent> 4141 - ); 4142 - }); 4143 - }); 4144 2981 4145 2982 export default memo(Status, (oldProps, newProps) => { 4146 2983 // Shallow equal all props except 'status'
+219 -219
src/locales/en.po
··· 34 34 35 35 #: src/components/account-block.jsx:190 36 36 #: src/components/account-info.jsx:710 37 - #: src/components/status.jsx:754 37 + #: src/components/status.jsx:519 38 38 msgid "Group" 39 39 msgstr "" 40 40 ··· 108 108 #: src/components/compose.jsx:2812 109 109 #: src/components/media-alt-modal.jsx:55 110 110 #: src/components/media-modal.jsx:363 111 - #: src/components/status.jsx:2006 112 - #: src/components/status.jsx:2023 113 - #: src/components/status.jsx:2154 114 - #: src/components/status.jsx:2778 115 - #: src/components/status.jsx:2781 111 + #: src/components/status.jsx:1771 112 + #: src/components/status.jsx:1788 113 + #: src/components/status.jsx:1919 114 + #: src/components/status.jsx:2543 115 + #: src/components/status.jsx:2546 116 116 #: src/pages/account-statuses.jsx:539 117 117 #: src/pages/accounts.jsx:118 118 118 #: src/pages/hashtag.jsx:203 ··· 217 217 msgstr "" 218 218 219 219 #: src/components/account-info.jsx:978 220 - #: src/components/status.jsx:2563 220 + #: src/components/status.jsx:2328 221 221 #: src/pages/catchup.jsx:71 222 222 #: src/pages/catchup.jsx:1448 223 223 #: src/pages/catchup.jsx:2061 ··· 343 343 msgstr "" 344 344 345 345 #: src/components/account-info.jsx:1522 346 - #: src/components/status.jsx:1428 346 + #: src/components/status.jsx:1193 347 347 msgid "Link copied" 348 348 msgstr "" 349 349 350 350 #: src/components/account-info.jsx:1525 351 - #: src/components/status.jsx:1431 351 + #: src/components/status.jsx:1196 352 352 msgid "Unable to copy link" 353 353 msgstr "" 354 354 355 355 #: src/components/account-info.jsx:1531 356 + #: src/components/post-embed-modal.jsx:232 356 357 #: src/components/shortcuts-settings.jsx:1059 357 - #: src/components/status.jsx:1437 358 - #: src/components/status.jsx:3556 358 + #: src/components/status.jsx:1202 359 359 msgid "Copy" 360 360 msgstr "" 361 361 362 362 #: src/components/account-info.jsx:1546 363 363 #: src/components/shortcuts-settings.jsx:1077 364 - #: src/components/status.jsx:1453 364 + #: src/components/status.jsx:1218 365 365 msgid "Sharing doesn't seem to work." 366 366 msgstr "" 367 367 368 368 #: src/components/account-info.jsx:1552 369 - #: src/components/status.jsx:1459 369 + #: src/components/status.jsx:1224 370 370 msgid "Share…" 371 371 msgstr "" 372 372 ··· 476 476 #: src/components/media-alt-modal.jsx:43 477 477 #: src/components/media-modal.jsx:327 478 478 #: src/components/notification-service.jsx:157 479 + #: src/components/post-embed-modal.jsx:196 479 480 #: src/components/report-modal.jsx:118 480 481 #: src/components/shortcuts-settings.jsx:230 481 482 #: src/components/shortcuts-settings.jsx:583 482 483 #: src/components/shortcuts-settings.jsx:783 483 - #: src/components/status.jsx:3280 484 - #: src/components/status.jsx:3520 485 - #: src/components/status.jsx:4029 484 + #: src/components/status.jsx:2742 485 + #: src/components/status.jsx:2954 486 486 #: src/pages/accounts.jsx:45 487 487 #: src/pages/catchup.jsx:1584 488 488 #: src/pages/filters.jsx:225 ··· 605 605 msgid "Cloak mode enabled" 606 606 msgstr "" 607 607 608 + #. More from [Author] 609 + #: src/components/byline.jsx:17 610 + msgid "More from <0/>" 611 + msgstr "More from <0/>" 612 + 608 613 #: src/components/columns.jsx:27 609 614 #: src/components/nav-menu.jsx:181 610 615 #: src/components/shortcuts-settings.jsx:139 ··· 732 737 msgstr "Attachment #{i} failed" 733 738 734 739 #: src/components/compose.jsx:1215 735 - #: src/components/status.jsx:2338 740 + #: src/components/status.jsx:2103 736 741 #: src/components/timeline.jsx:1015 737 742 msgid "Content warning" 738 743 msgstr "" ··· 742 747 msgstr "Content warning or sensitive media" 743 748 744 749 #: src/components/compose.jsx:1267 745 - #: src/components/status.jsx:101 750 + #: src/components/status.jsx:87 746 751 #: src/pages/settings.jsx:318 747 752 msgid "Public" 748 753 msgstr "" ··· 750 755 #: src/components/compose.jsx:1272 751 756 #: src/components/nav-menu.jsx:349 752 757 #: src/components/shortcuts-settings.jsx:165 753 - #: src/components/status.jsx:102 758 + #: src/components/status.jsx:88 754 759 msgid "Local" 755 760 msgstr "" 756 761 757 762 #: src/components/compose.jsx:1276 758 - #: src/components/status.jsx:103 763 + #: src/components/status.jsx:89 759 764 #: src/pages/settings.jsx:321 760 765 msgid "Unlisted" 761 766 msgstr "" 762 767 763 768 #: src/components/compose.jsx:1279 764 - #: src/components/status.jsx:104 769 + #: src/components/status.jsx:90 765 770 #: src/pages/settings.jsx:324 766 771 msgid "Followers only" 767 772 msgstr "" 768 773 769 774 #: src/components/compose.jsx:1282 770 - #: src/components/status.jsx:105 771 - #: src/components/status.jsx:2218 775 + #: src/components/status.jsx:91 776 + #: src/components/status.jsx:1983 772 777 msgid "Private mention" 773 778 msgstr "" 774 779 ··· 805 810 806 811 #: src/components/compose.jsx:1671 807 812 #: src/components/keyboard-shortcuts-help.jsx:155 808 - #: src/components/status.jsx:1200 809 - #: src/components/status.jsx:1986 810 - #: src/components/status.jsx:1987 811 - #: src/components/status.jsx:2682 813 + #: src/components/status.jsx:965 814 + #: src/components/status.jsx:1751 815 + #: src/components/status.jsx:1752 816 + #: src/components/status.jsx:2447 812 817 msgid "Reply" 813 818 msgstr "" 814 819 ··· 1030 1035 1031 1036 #: src/components/drafts.jsx:126 1032 1037 #: src/components/list-add-edit.jsx:188 1033 - #: src/components/status.jsx:1603 1038 + #: src/components/status.jsx:1368 1034 1039 #: src/pages/filters.jsx:603 1035 1040 #: src/pages/scheduled-posts.jsx:368 1036 1041 msgid "Delete…" ··· 1239 1244 msgstr "" 1240 1245 1241 1246 #: src/components/keyboard-shortcuts-help.jsx:176 1242 - #: src/components/status.jsx:1208 1243 - #: src/components/status.jsx:2709 1244 - #: src/components/status.jsx:2732 1245 - #: src/components/status.jsx:2733 1247 + #: src/components/status.jsx:973 1248 + #: src/components/status.jsx:2474 1249 + #: src/components/status.jsx:2497 1250 + #: src/components/status.jsx:2498 1246 1251 msgid "Boost" 1247 1252 msgstr "" 1248 1253 ··· 1251 1256 msgstr "" 1252 1257 1253 1258 #: src/components/keyboard-shortcuts-help.jsx:184 1254 - #: src/components/status.jsx:1271 1255 - #: src/components/status.jsx:2757 1256 - #: src/components/status.jsx:2758 1259 + #: src/components/status.jsx:1036 1260 + #: src/components/status.jsx:2522 1261 + #: src/components/status.jsx:2523 1257 1262 msgid "Bookmark" 1258 1263 msgstr "" 1259 1264 ··· 1312 1317 msgid "Posts on this list are hidden from Home/Following" 1313 1318 msgstr "Posts on this list are hidden from Home/Following" 1314 1319 1320 + #: src/components/math-block.jsx:132 1321 + msgid "Unable to format math" 1322 + msgstr "Unable to format math" 1323 + 1324 + #: src/components/math-block.jsx:146 1325 + msgid "Math expressions found." 1326 + msgstr "Math expressions found." 1327 + 1328 + #. Action to switch from rendered math back to raw (LaTeX) markup 1329 + #: src/components/math-block.jsx:149 1330 + msgid "Show markup" 1331 + msgstr "Show markup" 1332 + 1333 + #. Action to render math expressions from raw (LaTeX) markup 1334 + #: src/components/math-block.jsx:154 1335 + msgid "Format math" 1336 + msgstr "Format math" 1337 + 1315 1338 #: src/components/media-alt-modal.jsx:48 1316 1339 #: src/components/media.jsx:51 1317 1340 msgid "Media description" 1318 1341 msgstr "" 1319 1342 1320 1343 #: src/components/media-alt-modal.jsx:67 1321 - #: src/components/status.jsx:1314 1322 - #: src/components/status.jsx:1323 1344 + #: src/components/status.jsx:1079 1345 + #: src/components/status.jsx:1088 1323 1346 #: src/components/translation-block.jsx:239 1324 1347 msgid "Translate" 1325 1348 msgstr "" 1326 1349 1327 1350 #: src/components/media-alt-modal.jsx:78 1328 - #: src/components/status.jsx:1342 1351 + #: src/components/status.jsx:1107 1329 1352 msgid "Speak" 1330 1353 msgstr "" 1331 1354 ··· 1362 1385 msgstr "" 1363 1386 1364 1387 #: src/components/media-post.jsx:133 1365 - #: src/components/status.jsx:3859 1366 - #: src/components/status.jsx:3955 1367 - #: src/components/status.jsx:4033 1388 + #: src/components/status-compact.jsx:70 1389 + #: src/components/status.jsx:2880 1390 + #: src/components/status.jsx:2958 1368 1391 #: src/components/timeline.jsx:1004 1369 1392 #: src/pages/catchup.jsx:75 1370 1393 #: src/pages/catchup.jsx:1880 ··· 1676 1699 msgstr "" 1677 1700 1678 1701 #: src/components/notification.jsx:451 1679 - #: src/components/status.jsx:1285 1680 - #: src/components/status.jsx:1295 1702 + #: src/components/status.jsx:1050 1703 + #: src/components/status.jsx:1060 1681 1704 msgid "Boosted/Liked by…" 1682 1705 msgstr "" 1683 1706 ··· 1703 1726 msgstr "View #Wrapstodon" 1704 1727 1705 1728 #: src/components/notification.jsx:801 1706 - #: src/components/status.jsx:486 1729 + #: src/components/status.jsx:260 1707 1730 msgid "Read more →" 1708 1731 msgstr "" 1709 1732 ··· 1766 1789 msgid "Ending" 1767 1790 msgstr "" 1768 1791 1792 + #: src/components/post-embed-modal.jsx:201 1793 + #: src/components/status.jsx:1237 1794 + msgid "Embed post" 1795 + msgstr "" 1796 + 1797 + #: src/components/post-embed-modal.jsx:206 1798 + msgid "HTML Code" 1799 + msgstr "" 1800 + 1801 + #: src/components/post-embed-modal.jsx:223 1802 + msgid "HTML code copied" 1803 + msgstr "" 1804 + 1805 + #: src/components/post-embed-modal.jsx:226 1806 + msgid "Unable to copy HTML code" 1807 + msgstr "" 1808 + 1809 + #: src/components/post-embed-modal.jsx:238 1810 + msgid "Media attachments:" 1811 + msgstr "" 1812 + 1813 + #: src/components/post-embed-modal.jsx:260 1814 + msgid "Account Emojis:" 1815 + msgstr "" 1816 + 1817 + #: src/components/post-embed-modal.jsx:291 1818 + #: src/components/post-embed-modal.jsx:336 1819 + msgid "static URL" 1820 + msgstr "" 1821 + 1822 + #: src/components/post-embed-modal.jsx:305 1823 + msgid "Emojis:" 1824 + msgstr "" 1825 + 1826 + #: src/components/post-embed-modal.jsx:350 1827 + msgid "Notes:" 1828 + msgstr "" 1829 + 1830 + #: src/components/post-embed-modal.jsx:354 1831 + msgid "This is static, unstyled and scriptless. You may need to apply your own styles and edit as needed." 1832 + msgstr "" 1833 + 1834 + #: src/components/post-embed-modal.jsx:360 1835 + msgid "Polls are not interactive, becomes a list with vote counts." 1836 + msgstr "" 1837 + 1838 + #: src/components/post-embed-modal.jsx:365 1839 + msgid "Media attachments can be images, videos, audios or any file types." 1840 + msgstr "" 1841 + 1842 + #: src/components/post-embed-modal.jsx:371 1843 + msgid "Post could be edited or deleted later." 1844 + msgstr "" 1845 + 1846 + #: src/components/post-embed-modal.jsx:377 1847 + msgid "Preview" 1848 + msgstr "" 1849 + 1850 + #: src/components/post-embed-modal.jsx:386 1851 + msgid "Note: This preview is lightly styled." 1852 + msgstr "" 1853 + 1769 1854 #: src/components/recent-searches.jsx:27 1770 1855 msgid "Cleared recent searches" 1771 1856 msgstr "Cleared recent searches" ··· 2030 2115 msgstr "" 2031 2116 2032 2117 #: src/components/shortcuts-settings.jsx:379 2033 - #: src/components/status.jsx:1565 2118 + #: src/components/status.jsx:1330 2034 2119 #: src/pages/list.jsx:195 2035 2120 msgid "Edit" 2036 2121 msgstr "" ··· 2229 2314 msgid "Import/export settings from/to instance server (Very experimental)" 2230 2315 msgstr "" 2231 2316 2232 - #: src/components/status.jsx:351 2233 - msgid "Unable to format math" 2234 - msgstr "Unable to format math" 2235 - 2236 - #: src/components/status.jsx:365 2237 - msgid "Math expressions found." 2238 - msgstr "Math expressions found." 2239 - 2240 - #. Action to switch from rendered math back to raw (LaTeX) markup 2241 - #: src/components/status.jsx:368 2242 - msgid "Show markup" 2243 - msgstr "Show markup" 2244 - 2245 - #. Action to render math expressions from raw (LaTeX) markup 2246 - #: src/components/status.jsx:373 2247 - msgid "Format math" 2248 - msgstr "Format math" 2249 - 2250 - #: src/components/status.jsx:778 2317 + #: src/components/status.jsx:543 2251 2318 msgid "<0/> <1>boosted</1>" 2252 2319 msgstr "<0/> <1>boosted</1>" 2253 2320 2254 - #: src/components/status.jsx:881 2321 + #: src/components/status.jsx:646 2255 2322 msgid "Sorry, your current logged-in instance can't interact with this post from another instance." 2256 2323 msgstr "" 2257 2324 2258 2325 #. placeholder {0}: username || acct 2259 - #: src/components/status.jsx:1035 2326 + #: src/components/status.jsx:800 2260 2327 msgid "Unliked @{0}'s post" 2261 2328 msgstr "" 2262 2329 2263 2330 #. placeholder {0}: username || acct 2264 - #: src/components/status.jsx:1036 2331 + #: src/components/status.jsx:801 2265 2332 msgid "Liked @{0}'s post" 2266 2333 msgstr "Liked @{0}'s post" 2267 2334 2268 2335 #. placeholder {0}: username || acct 2269 - #: src/components/status.jsx:1075 2336 + #: src/components/status.jsx:840 2270 2337 msgid "Unbookmarked @{0}'s post" 2271 2338 msgstr "Unbookmarked @{0}'s post" 2272 2339 2273 2340 #. placeholder {0}: username || acct 2274 - #: src/components/status.jsx:1076 2341 + #: src/components/status.jsx:841 2275 2342 msgid "Bookmarked @{0}'s post" 2276 2343 msgstr "Bookmarked @{0}'s post" 2277 2344 2278 - #: src/components/status.jsx:1177 2345 + #: src/components/status.jsx:942 2279 2346 msgid "Some media have no descriptions." 2280 2347 msgstr "" 2281 2348 2282 2349 #. placeholder {0}: rtf.format(-statusMonthsAgo, 'month') 2283 - #: src/components/status.jsx:1184 2350 + #: src/components/status.jsx:949 2284 2351 msgid "Old post (<0>{0}</0>)" 2285 2352 msgstr "" 2286 2353 2287 - #: src/components/status.jsx:1208 2288 - #: src/components/status.jsx:1248 2289 - #: src/components/status.jsx:2709 2290 - #: src/components/status.jsx:2732 2354 + #: src/components/status.jsx:973 2355 + #: src/components/status.jsx:1013 2356 + #: src/components/status.jsx:2474 2357 + #: src/components/status.jsx:2497 2291 2358 msgid "Unboost" 2292 2359 msgstr "" 2293 2360 2294 - #: src/components/status.jsx:1224 2295 - #: src/components/status.jsx:2724 2361 + #: src/components/status.jsx:989 2362 + #: src/components/status.jsx:2489 2296 2363 msgid "Quote" 2297 2364 msgstr "" 2298 2365 2299 2366 #. placeholder {0}: username || acct 2300 - #: src/components/status.jsx:1236 2301 - #: src/components/status.jsx:1702 2367 + #: src/components/status.jsx:1001 2368 + #: src/components/status.jsx:1467 2302 2369 msgid "Unboosted @{0}'s post" 2303 2370 msgstr "Unboosted @{0}'s post" 2304 2371 2305 2372 #. placeholder {0}: username || acct 2306 - #: src/components/status.jsx:1237 2307 - #: src/components/status.jsx:1703 2373 + #: src/components/status.jsx:1002 2374 + #: src/components/status.jsx:1468 2308 2375 msgid "Boosted @{0}'s post" 2309 2376 msgstr "Boosted @{0}'s post" 2310 2377 2311 - #: src/components/status.jsx:1249 2378 + #: src/components/status.jsx:1014 2312 2379 msgid "Boost…" 2313 2380 msgstr "" 2314 2381 2315 - #: src/components/status.jsx:1261 2316 - #: src/components/status.jsx:1996 2317 - #: src/components/status.jsx:2745 2382 + #: src/components/status.jsx:1026 2383 + #: src/components/status.jsx:1761 2384 + #: src/components/status.jsx:2510 2318 2385 msgid "Unlike" 2319 2386 msgstr "" 2320 2387 2321 - #: src/components/status.jsx:1262 2322 - #: src/components/status.jsx:1996 2323 - #: src/components/status.jsx:1997 2324 - #: src/components/status.jsx:2745 2325 - #: src/components/status.jsx:2746 2388 + #: src/components/status.jsx:1027 2389 + #: src/components/status.jsx:1761 2390 + #: src/components/status.jsx:1762 2391 + #: src/components/status.jsx:2510 2392 + #: src/components/status.jsx:2511 2326 2393 msgid "Like" 2327 2394 msgstr "" 2328 2395 2329 - #: src/components/status.jsx:1271 2330 - #: src/components/status.jsx:2757 2396 + #: src/components/status.jsx:1036 2397 + #: src/components/status.jsx:2522 2331 2398 msgid "Unbookmark" 2332 2399 msgstr "" 2333 2400 2334 - #: src/components/status.jsx:1354 2401 + #: src/components/status.jsx:1119 2335 2402 msgid "Post text copied" 2336 2403 msgstr "Post text copied" 2337 2404 2338 - #: src/components/status.jsx:1357 2405 + #: src/components/status.jsx:1122 2339 2406 msgid "Unable to copy post text" 2340 2407 msgstr "Unable to copy post text" 2341 2408 2342 - #: src/components/status.jsx:1363 2409 + #: src/components/status.jsx:1128 2343 2410 msgid "Copy post text" 2344 2411 msgstr "Copy post text" 2345 2412 2346 2413 #. placeholder {0}: username || acct 2347 - #: src/components/status.jsx:1381 2414 + #: src/components/status.jsx:1146 2348 2415 msgid "View post by <0>@{0}</0>" 2349 2416 msgstr "" 2350 2417 2351 - #: src/components/status.jsx:1402 2418 + #: src/components/status.jsx:1167 2352 2419 msgid "Show Edit History" 2353 2420 msgstr "" 2354 2421 2355 - #: src/components/status.jsx:1405 2422 + #: src/components/status.jsx:1170 2356 2423 msgid "Edited: {editedDateText}" 2357 2424 msgstr "" 2358 2425 2359 - #: src/components/status.jsx:1472 2360 - #: src/components/status.jsx:3525 2361 - msgid "Embed post" 2362 - msgstr "" 2363 - 2364 - #: src/components/status.jsx:1486 2426 + #: src/components/status.jsx:1251 2365 2427 msgid "Conversation unmuted" 2366 2428 msgstr "" 2367 2429 2368 - #: src/components/status.jsx:1486 2430 + #: src/components/status.jsx:1251 2369 2431 msgid "Conversation muted" 2370 2432 msgstr "" 2371 2433 2372 - #: src/components/status.jsx:1492 2434 + #: src/components/status.jsx:1257 2373 2435 msgid "Unable to unmute conversation" 2374 2436 msgstr "" 2375 2437 2376 - #: src/components/status.jsx:1493 2438 + #: src/components/status.jsx:1258 2377 2439 msgid "Unable to mute conversation" 2378 2440 msgstr "" 2379 2441 2380 - #: src/components/status.jsx:1502 2442 + #: src/components/status.jsx:1267 2381 2443 msgid "Unmute conversation" 2382 2444 msgstr "" 2383 2445 2384 - #: src/components/status.jsx:1509 2446 + #: src/components/status.jsx:1274 2385 2447 msgid "Mute conversation" 2386 2448 msgstr "" 2387 2449 2388 - #: src/components/status.jsx:1525 2450 + #: src/components/status.jsx:1290 2389 2451 msgid "Post unpinned from profile" 2390 2452 msgstr "" 2391 2453 2392 - #: src/components/status.jsx:1526 2454 + #: src/components/status.jsx:1291 2393 2455 msgid "Post pinned to profile" 2394 2456 msgstr "" 2395 2457 2396 - #: src/components/status.jsx:1531 2458 + #: src/components/status.jsx:1296 2397 2459 msgid "Unable to unpin post" 2398 2460 msgstr "" 2399 2461 2400 - #: src/components/status.jsx:1531 2462 + #: src/components/status.jsx:1296 2401 2463 msgid "Unable to pin post" 2402 2464 msgstr "" 2403 2465 2404 - #: src/components/status.jsx:1540 2466 + #: src/components/status.jsx:1305 2405 2467 msgid "Unpin from profile" 2406 2468 msgstr "" 2407 2469 2408 - #: src/components/status.jsx:1547 2470 + #: src/components/status.jsx:1312 2409 2471 msgid "Pin to profile" 2410 2472 msgstr "" 2411 2473 2412 - #: src/components/status.jsx:1576 2474 + #: src/components/status.jsx:1341 2413 2475 msgid "Delete this post?" 2414 2476 msgstr "" 2415 2477 2416 - #: src/components/status.jsx:1592 2478 + #: src/components/status.jsx:1357 2417 2479 msgid "Post deleted" 2418 2480 msgstr "" 2419 2481 2420 - #: src/components/status.jsx:1595 2482 + #: src/components/status.jsx:1360 2421 2483 msgid "Unable to delete post" 2422 2484 msgstr "" 2423 2485 2424 - #: src/components/status.jsx:1623 2486 + #: src/components/status.jsx:1388 2425 2487 msgid "Report post…" 2426 2488 msgstr "" 2427 2489 2428 - #: src/components/status.jsx:1997 2429 - #: src/components/status.jsx:2033 2430 - #: src/components/status.jsx:2746 2490 + #: src/components/status.jsx:1762 2491 + #: src/components/status.jsx:1798 2492 + #: src/components/status.jsx:2511 2431 2493 msgid "Liked" 2432 2494 msgstr "" 2433 2495 2434 - #: src/components/status.jsx:2030 2435 - #: src/components/status.jsx:2733 2496 + #: src/components/status.jsx:1795 2497 + #: src/components/status.jsx:2498 2436 2498 msgid "Boosted" 2437 2499 msgstr "" 2438 2500 2439 - #: src/components/status.jsx:2040 2440 - #: src/components/status.jsx:2758 2501 + #: src/components/status.jsx:1805 2502 + #: src/components/status.jsx:2523 2441 2503 msgid "Bookmarked" 2442 2504 msgstr "" 2443 2505 2444 - #: src/components/status.jsx:2044 2506 + #: src/components/status.jsx:1809 2445 2507 msgid "Pinned" 2446 2508 msgstr "" 2447 2509 2448 - #: src/components/status.jsx:2096 2449 - #: src/components/status.jsx:2571 2510 + #: src/components/status.jsx:1861 2511 + #: src/components/status.jsx:2336 2450 2512 msgid "Deleted" 2451 2513 msgstr "" 2452 2514 2453 - #: src/components/status.jsx:2137 2515 + #: src/components/status.jsx:1902 2454 2516 msgid "{repliesCount, plural, one {# reply} other {# replies}}" 2455 2517 msgstr "" 2456 2518 2457 - #: src/components/status.jsx:2301 2458 - #: src/components/status.jsx:2363 2459 - #: src/components/status.jsx:2467 2519 + #: src/components/status.jsx:2066 2520 + #: src/components/status.jsx:2128 2521 + #: src/components/status.jsx:2232 2460 2522 msgid "Show less" 2461 2523 msgstr "" 2462 2524 2463 - #: src/components/status.jsx:2301 2464 - #: src/components/status.jsx:2363 2525 + #: src/components/status.jsx:2066 2526 + #: src/components/status.jsx:2128 2465 2527 msgid "Show content" 2466 2528 msgstr "" 2467 2529 2468 2530 #. placeholder {0}: filterInfo.titlesStr 2469 2531 #. placeholder {0}: filterInfo?.titlesStr 2470 - #: src/components/status.jsx:2463 2532 + #: src/components/status.jsx:2228 2471 2533 #: src/pages/catchup.jsx:1879 2472 2534 msgid "Filtered: {0}" 2473 2535 msgstr "Filtered: {0}" 2474 2536 2475 - #: src/components/status.jsx:2467 2537 + #: src/components/status.jsx:2232 2476 2538 msgid "Show media" 2477 2539 msgstr "" 2478 2540 2479 - #: src/components/status.jsx:2606 2541 + #: src/components/status.jsx:2371 2480 2542 msgid "Edited" 2481 2543 msgstr "" 2482 2544 2483 - #: src/components/status.jsx:2683 2545 + #: src/components/status.jsx:2448 2484 2546 msgid "Comments" 2485 2547 msgstr "" 2486 2548 2487 - #. More from [Author] 2488 - #: src/components/status.jsx:2983 2489 - msgid "More from <0/>" 2490 - msgstr "More from <0/>" 2549 + #: src/components/status.jsx:2633 2550 + msgid "Post hidden by your filters" 2551 + msgstr "Post hidden by your filters" 2552 + 2553 + #: src/components/status.jsx:2634 2554 + msgid "Post pending" 2555 + msgstr "Post pending" 2556 + 2557 + #: src/components/status.jsx:2635 2558 + #: src/components/status.jsx:2636 2559 + #: src/components/status.jsx:2637 2560 + #: src/components/status.jsx:2638 2561 + msgid "Post unavailable" 2562 + msgstr "Post unavailable" 2491 2563 2492 - #: src/components/status.jsx:3285 2564 + #: src/components/status.jsx:2747 2493 2565 msgid "Edit History" 2494 2566 msgstr "" 2495 2567 2496 - #: src/components/status.jsx:3289 2568 + #: src/components/status.jsx:2751 2497 2569 msgid "Failed to load history" 2498 2570 msgstr "" 2499 2571 2500 - #: src/components/status.jsx:3294 2572 + #: src/components/status.jsx:2756 2501 2573 #: src/pages/annual-report.jsx:45 2502 2574 msgid "Loading…" 2503 2575 msgstr "" 2504 2576 2505 - #: src/components/status.jsx:3530 2506 - msgid "HTML Code" 2507 - msgstr "" 2508 - 2509 - #: src/components/status.jsx:3547 2510 - msgid "HTML code copied" 2511 - msgstr "" 2512 - 2513 - #: src/components/status.jsx:3550 2514 - msgid "Unable to copy HTML code" 2515 - msgstr "" 2516 - 2517 - #: src/components/status.jsx:3562 2518 - msgid "Media attachments:" 2519 - msgstr "" 2520 - 2521 - #: src/components/status.jsx:3584 2522 - msgid "Account Emojis:" 2523 - msgstr "" 2524 - 2525 - #: src/components/status.jsx:3615 2526 - #: src/components/status.jsx:3660 2527 - msgid "static URL" 2528 - msgstr "" 2529 - 2530 - #: src/components/status.jsx:3629 2531 - msgid "Emojis:" 2532 - msgstr "" 2533 - 2534 - #: src/components/status.jsx:3674 2535 - msgid "Notes:" 2536 - msgstr "" 2537 - 2538 - #: src/components/status.jsx:3678 2539 - msgid "This is static, unstyled and scriptless. You may need to apply your own styles and edit as needed." 2540 - msgstr "" 2541 - 2542 - #: src/components/status.jsx:3684 2543 - msgid "Polls are not interactive, becomes a list with vote counts." 2544 - msgstr "" 2545 - 2546 - #: src/components/status.jsx:3689 2547 - msgid "Media attachments can be images, videos, audios or any file types." 2548 - msgstr "" 2549 - 2550 - #: src/components/status.jsx:3695 2551 - msgid "Post could be edited or deleted later." 2552 - msgstr "" 2553 - 2554 - #: src/components/status.jsx:3701 2555 - msgid "Preview" 2556 - msgstr "" 2557 - 2558 - #: src/components/status.jsx:3710 2559 - msgid "Note: This preview is lightly styled." 2560 - msgstr "" 2561 - 2562 2577 #. [Name] [Visibility icon] boosted 2563 - #: src/components/status.jsx:3963 2578 + #: src/components/status.jsx:2888 2564 2579 msgid "<0/> <1/> boosted" 2565 2580 msgstr "<0/> <1/> boosted" 2566 - 2567 - #: src/components/status.jsx:4065 2568 - msgid "Post hidden by your filters" 2569 - msgstr "Post hidden by your filters" 2570 - 2571 - #: src/components/status.jsx:4066 2572 - msgid "Post pending" 2573 - msgstr "Post pending" 2574 - 2575 - #: src/components/status.jsx:4067 2576 - #: src/components/status.jsx:4068 2577 - #: src/components/status.jsx:4069 2578 - #: src/components/status.jsx:4070 2579 - msgid "Post unavailable" 2580 - msgstr "Post unavailable" 2581 2581 2582 2582 #: src/components/thread-badge.jsx:22 2583 2583 #: src/components/thread-badge.jsx:37
+11
src/utils/store-utils.js
··· 1 + import mem from './mem'; 1 2 import store from './store'; 2 3 3 4 export function getAccounts() { ··· 49 50 } 50 51 return null; 51 52 } 53 + 54 + // Memoized version of getCurrentAccountID for performance 55 + export const getCurrentAccID = mem( 56 + () => { 57 + return getCurrentAccountID(); 58 + }, 59 + { 60 + maxAge: 60 * 1000, // 1 minute 61 + }, 62 + ); 52 63 53 64 export function setCurrentAccountID(id) { 54 65 try {