this repo has no description
0
fork

Configure Feed

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

at main 290 lines 8.3 kB view raw
1import '@justinribeiro/lite-youtube'; 2 3import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; 4import { useCallback, useEffect, useState } from 'preact/hooks'; 5import { useSnapshot } from 'valtio'; 6 7import getDomain from '../utils/get-domain'; 8import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; 9import states from '../utils/states'; 10import unfurlMastodonLink from '../utils/unfurl-link'; 11 12import Byline from './byline'; 13import Icon from './icon'; 14import 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" 20function 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 31function 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 290export default StatusCard;