this repo has no description
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 && <>· </>}
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 && <>· </>}
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;