Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

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

Remove embed player dependency

Grace Kind (May 3, 2026, 10:45 PM -0500) a2fc5e1c c96925e0

+31 -500
-493
src/js/lib/embed-player.js
··· 1 - // Adapted from: 2 - // https://github.com/bluesky-social/social-app/blob/main/src/lib/strings/embed-player.ts 3 - 4 - import { TENOR_GIF_PROXY_URL } from "/js/config.js"; 5 - 6 - const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 7 - const isWeb = true; 8 - const IFRAME_HOST = ""; 9 - 10 - const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i; 11 - const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i; 12 - 13 - export function parseEmbedPlayerFromUrl(url) { 14 - let urlp; 15 - try { 16 - urlp = new URL(url); 17 - } catch (e) { 18 - return undefined; 19 - } 20 - 21 - // youtube 22 - if (urlp.hostname === "youtu.be") { 23 - const videoId = urlp.pathname.split("/")[1]; 24 - const t = urlp.searchParams.get("t") ?? "0"; 25 - const seek = encodeURIComponent(t.replace(/s$/, "")); 26 - 27 - if (videoId) { 28 - return { 29 - type: "youtube_video", 30 - source: "youtube", 31 - playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 32 - }; 33 - } 34 - } 35 - if ( 36 - urlp.hostname === "www.youtube.com" || 37 - urlp.hostname === "youtube.com" || 38 - urlp.hostname === "m.youtube.com" || 39 - urlp.hostname === "music.youtube.com" 40 - ) { 41 - const [_, page, shortOrLiveVideoId] = urlp.pathname.split("/"); 42 - 43 - const isShorts = page === "shorts"; 44 - const isLive = page === "live"; 45 - const videoId = 46 - isShorts || isLive ? shortOrLiveVideoId : urlp.searchParams.get("v"); 47 - const t = urlp.searchParams.get("t") ?? "0"; 48 - const seek = encodeURIComponent(t.replace(/s$/, "")); 49 - 50 - if (videoId) { 51 - return { 52 - type: isShorts ? "youtube_short" : "youtube_video", 53 - source: isShorts ? "youtubeShorts" : "youtube", 54 - hideDetails: isShorts ? true : undefined, 55 - playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 56 - }; 57 - } 58 - } 59 - 60 - // twitch 61 - if ( 62 - urlp.hostname === "twitch.tv" || 63 - urlp.hostname === "www.twitch.tv" || 64 - urlp.hostname === "m.twitch.tv" 65 - ) { 66 - const parent = isWeb 67 - ? // @ts-ignore only for web 68 - window.location.hostname 69 - : "localhost"; 70 - 71 - const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split("/"); 72 - 73 - if (channelOrVideo === "videos") { 74 - return { 75 - type: "twitch_video", 76 - source: "twitch", 77 - playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`, 78 - }; 79 - } else if (clipOrId === "clip") { 80 - return { 81 - type: "twitch_video", 82 - source: "twitch", 83 - playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`, 84 - }; 85 - } else if (channelOrVideo) { 86 - return { 87 - type: "twitch_video", 88 - source: "twitch", 89 - playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`, 90 - }; 91 - } 92 - } 93 - 94 - // spotify 95 - if (urlp.hostname === "open.spotify.com") { 96 - const [_, typeOrLocale, idOrType, id] = urlp.pathname.split("/"); 97 - 98 - if (idOrType) { 99 - if (typeOrLocale === "playlist" || idOrType === "playlist") { 100 - return { 101 - type: "spotify_playlist", 102 - source: "spotify", 103 - playerUri: `https://open.spotify.com/embed/playlist/${ 104 - id ?? idOrType 105 - }`, 106 - }; 107 - } 108 - if (typeOrLocale === "album" || idOrType === "album") { 109 - return { 110 - type: "spotify_album", 111 - source: "spotify", 112 - playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`, 113 - }; 114 - } 115 - if (typeOrLocale === "track" || idOrType === "track") { 116 - return { 117 - type: "spotify_song", 118 - source: "spotify", 119 - playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`, 120 - }; 121 - } 122 - if (typeOrLocale === "episode" || idOrType === "episode") { 123 - return { 124 - type: "spotify_song", 125 - source: "spotify", 126 - playerUri: `https://open.spotify.com/embed/episode/${id ?? idOrType}`, 127 - }; 128 - } 129 - if (typeOrLocale === "show" || idOrType === "show") { 130 - return { 131 - type: "spotify_song", 132 - source: "spotify", 133 - playerUri: `https://open.spotify.com/embed/show/${id ?? idOrType}`, 134 - }; 135 - } 136 - } 137 - } 138 - 139 - // soundcloud 140 - if ( 141 - urlp.hostname === "soundcloud.com" || 142 - urlp.hostname === "www.soundcloud.com" 143 - ) { 144 - const [_, user, trackOrSets, set] = urlp.pathname.split("/"); 145 - 146 - if (user && trackOrSets) { 147 - if (trackOrSets === "sets" && set) { 148 - return { 149 - type: "soundcloud_set", 150 - source: "soundcloud", 151 - playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, 152 - }; 153 - } 154 - 155 - return { 156 - type: "soundcloud_track", 157 - source: "soundcloud", 158 - playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, 159 - }; 160 - } 161 - } 162 - 163 - if ( 164 - urlp.hostname === "music.apple.com" || 165 - urlp.hostname === "music.apple.com" 166 - ) { 167 - // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want 168 - // to check if the length is correct 169 - const pathParams = urlp.pathname.split("/"); 170 - const type = pathParams[2]; 171 - const songId = urlp.searchParams.get("i"); 172 - 173 - if (pathParams.length === 5 && (type === "playlist" || type === "album")) { 174 - // We want to append the songId to the end of the url if it exists 175 - const embedUri = `https://embed.music.apple.com${urlp.pathname}${ 176 - urlp.search ? "?i=" + songId : "" 177 - }`; 178 - 179 - if (type === "playlist") { 180 - return { 181 - type: "apple_music_playlist", 182 - source: "appleMusic", 183 - playerUri: embedUri, 184 - }; 185 - } else if (type === "album") { 186 - if (songId) { 187 - return { 188 - type: "apple_music_song", 189 - source: "appleMusic", 190 - playerUri: embedUri, 191 - }; 192 - } else { 193 - return { 194 - type: "apple_music_album", 195 - source: "appleMusic", 196 - playerUri: embedUri, 197 - }; 198 - } 199 - } 200 - } 201 - } 202 - 203 - if (urlp.hostname === "vimeo.com" || urlp.hostname === "www.vimeo.com") { 204 - const [_, videoId] = urlp.pathname.split("/"); 205 - if (videoId) { 206 - return { 207 - type: "vimeo_video", 208 - source: "vimeo", 209 - playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`, 210 - }; 211 - } 212 - } 213 - 214 - if (urlp.hostname === "giphy.com" || urlp.hostname === "www.giphy.com") { 215 - const [_, gifs, nameAndId] = urlp.pathname.split("/"); 216 - 217 - /* 218 - * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name) 219 - * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can 220 - * use it in an <Image> component 221 - */ 222 - 223 - if (gifs === "gifs" && nameAndId) { 224 - const gifId = nameAndId.split("-").pop(); 225 - 226 - if (gifId) { 227 - return { 228 - type: "giphy_gif", 229 - source: "giphy", 230 - isGif: true, 231 - hideDetails: true, 232 - metaUri: `https://giphy.com/gifs/${gifId}`, 233 - playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, 234 - }; 235 - } 236 - } 237 - } 238 - 239 - // There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com 240 - // These can include (presumably) a tracking id in the path name, so we have to check for that as well 241 - if (giphyRegex.test(urlp.hostname)) { 242 - // We can link directly to the gif, if its a proper link 243 - const [_, media, trackingOrId, idOrFilename, filename] = 244 - urlp.pathname.split("/"); 245 - 246 - if (media === "media") { 247 - if (idOrFilename && gifFilenameRegex.test(idOrFilename)) { 248 - return { 249 - type: "giphy_gif", 250 - source: "giphy", 251 - isGif: true, 252 - hideDetails: true, 253 - metaUri: `https://giphy.com/gifs/${trackingOrId}`, 254 - playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`, 255 - }; 256 - } else if (filename && gifFilenameRegex.test(filename)) { 257 - return { 258 - type: "giphy_gif", 259 - source: "giphy", 260 - isGif: true, 261 - hideDetails: true, 262 - metaUri: `https://giphy.com/gifs/${idOrFilename}`, 263 - playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`, 264 - }; 265 - } 266 - } 267 - } 268 - 269 - // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also 270 - // be .webp 271 - if (urlp.hostname === "i.giphy.com" || urlp.hostname === "www.i.giphy.com") { 272 - const [_, mediaOrFilename, filename] = urlp.pathname.split("/"); 273 - 274 - if (mediaOrFilename === "media" && filename) { 275 - const gifId = filename.split(".")[0]; 276 - return { 277 - type: "giphy_gif", 278 - source: "giphy", 279 - isGif: true, 280 - hideDetails: true, 281 - metaUri: `https://giphy.com/gifs/${gifId}`, 282 - playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, 283 - }; 284 - } else if (mediaOrFilename) { 285 - const gifId = mediaOrFilename.split(".")[0]; 286 - return { 287 - type: "giphy_gif", 288 - source: "giphy", 289 - isGif: true, 290 - hideDetails: true, 291 - metaUri: `https://giphy.com/gifs/${gifId}`, 292 - playerUri: `https://i.giphy.com/media/${ 293 - mediaOrFilename.split(".")[0] 294 - }/200.webp`, 295 - }; 296 - } 297 - } 298 - 299 - const tenorGif = parseTenorGif(urlp); 300 - if (tenorGif.success) { 301 - const { playerUri, dimensions } = tenorGif; 302 - 303 - return { 304 - type: "tenor_gif", 305 - source: "tenor", 306 - isGif: true, 307 - hideDetails: true, 308 - playerUri, 309 - dimensions, 310 - }; 311 - } 312 - 313 - // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path 314 - if (urlp.hostname === "www.flickr.com" || urlp.hostname === "flickr.com") { 315 - let i = urlp.pathname.length - 1; 316 - while (i > 0 && urlp.pathname.charAt(i) === "/") { 317 - --i; 318 - } 319 - 320 - const path_components = urlp.pathname.slice(1, i + 1).split("/"); 321 - if (path_components.length === 4) { 322 - // discard username - it's not relevant 323 - const [photos, _, albums, id] = path_components; 324 - if (photos === "photos" && albums === "albums") { 325 - // this at least has the shape of a valid photo-album URL! 326 - return { 327 - type: "flickr_album", 328 - source: "flickr", 329 - playerUri: `https://embedr.flickr.com/photosets/${id}`, 330 - }; 331 - } 332 - } 333 - 334 - if (path_components.length === 3) { 335 - const [groups, id, pool] = path_components; 336 - if (groups === "groups" && pool === "pool") { 337 - return { 338 - type: "flickr_album", 339 - source: "flickr", 340 - playerUri: `https://embedr.flickr.com/groups/${id}`, 341 - }; 342 - } 343 - } 344 - // not an album or a group pool, don't know what to do with this! 345 - return undefined; 346 - } 347 - 348 - // link shortened flickr path 349 - if (urlp.hostname === "flic.kr") { 350 - const b58alph = 351 - "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; 352 - let [_, type, idBase58Enc] = urlp.pathname.split("/"); 353 - let id = 0n; 354 - for (const char of idBase58Enc) { 355 - const nextIdx = b58alph.indexOf(char); 356 - if (nextIdx >= 0) { 357 - id = id * 58n + BigInt(nextIdx); 358 - } else { 359 - // not b58 encoded, ergo not a valid link to embed 360 - return undefined; 361 - } 362 - } 363 - 364 - switch (type) { 365 - case "go": 366 - const formattedGroupId = `${id}`; 367 - return { 368 - type: "flickr_album", 369 - source: "flickr", 370 - playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice( 371 - 0, 372 - -2 373 - )}@N${formattedGroupId.slice(-2)}`, 374 - }; 375 - case "s": 376 - return { 377 - type: "flickr_album", 378 - source: "flickr", 379 - playerUri: `https://embedr.flickr.com/photosets/${id}`, 380 - }; 381 - default: 382 - // we don't know what this is so we can't embed it 383 - return undefined; 384 - } 385 - } 386 - } 387 - 388 - export function getPlayerAspect({ type, hasThumb, width }) { 389 - if (!hasThumb) return { aspectRatio: 16 / 9 }; 390 - 391 - switch (type) { 392 - case "youtube_video": 393 - case "twitch_video": 394 - case "vimeo_video": 395 - return { aspectRatio: 16 / 9 }; 396 - case "youtube_short": 397 - if (SCREEN_HEIGHT < 600) { 398 - return { aspectRatio: (9 / 16) * 1.75 }; 399 - } else { 400 - return { aspectRatio: (9 / 16) * 1.5 }; 401 - } 402 - case "spotify_album": 403 - case "apple_music_album": 404 - case "apple_music_playlist": 405 - case "spotify_playlist": 406 - case "soundcloud_set": 407 - return { height: 380 }; 408 - case "spotify_song": 409 - if (width <= 300) { 410 - return { height: 155 }; 411 - } 412 - return { height: 232 }; 413 - case "soundcloud_track": 414 - return { height: 165 }; 415 - case "apple_music_song": 416 - return { height: 150 }; 417 - default: 418 - return { aspectRatio: 16 / 9 }; 419 - } 420 - } 421 - 422 - export function getGifDims(originalHeight, originalWidth, viewWidth) { 423 - const scaledHeight = (originalHeight / originalWidth) * viewWidth; 424 - 425 - return { 426 - height: scaledHeight > 250 ? 250 : scaledHeight, 427 - width: (250 / scaledHeight) * viewWidth, 428 - }; 429 - } 430 - 431 - export function getGiphyMetaUri(url) { 432 - if (giphyRegex.test(url.hostname) || url.hostname === "i.giphy.com") { 433 - const params = parseEmbedPlayerFromUrl(url.toString()); 434 - if (params && params.type === "giphy_gif") { 435 - return params.metaUri; 436 - } 437 - } 438 - } 439 - 440 - export function parseTenorGif(urlp) { 441 - if (urlp.hostname !== "media.tenor.com") { 442 - return { success: false }; 443 - } 444 - 445 - let [_, id, filename] = urlp.pathname.split("/"); 446 - 447 - if (!id || !filename) { 448 - return { success: false }; 449 - } 450 - 451 - if (!id.includes("AAAAC")) { 452 - return { success: false }; 453 - } 454 - 455 - const h = urlp.searchParams.get("hh"); 456 - const w = urlp.searchParams.get("ww"); 457 - 458 - if (!h || !w) { 459 - return { success: false }; 460 - } 461 - 462 - const dimensions = { 463 - height: Number(h), 464 - width: Number(w), 465 - }; 466 - 467 - if (isWeb) { 468 - if (isSafari) { 469 - id = id.replace("AAAAC", "AAAP1"); 470 - filename = filename.replace(".gif", ".mp4"); 471 - } else { 472 - id = id.replace("AAAAC", "AAAP3"); 473 - filename = filename.replace(".gif", ".webm"); 474 - } 475 - } else { 476 - id = id.replace("AAAAC", "AAAAM"); 477 - } 478 - 479 - return { 480 - success: true, 481 - playerUri: `${TENOR_GIF_PROXY_URL}/${id}/${filename}`, 482 - dimensions, 483 - }; 484 - } 485 - 486 - export function isTenorGifUri(url) { 487 - try { 488 - return parseTenorGif(typeof url === "string" ? new URL(url) : url).success; 489 - } catch { 490 - // Invalid URL 491 - return false; 492 - } 493 - }
+29 -7
src/js/templates/postEmbed.template.js
··· 8 8 import { avatarTemplate } from "/js/templates/avatar.template.js"; 9 9 import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; 10 10 import { richTextTemplate } from "/js/templates/richText.template.js"; 11 - import { parseEmbedPlayerFromUrl } from "/js/lib/embed-player.js"; 12 11 import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; 13 12 import { postLabelsTemplate } from "/js/templates/postLabels.template.js"; 14 13 import { linkToPost, linkToFeed } from "/js/navigation.js"; 15 14 import { moderationWarningTemplate } from "/js/templates/moderationWarning.template.js"; 16 - import { OG_CARD_SERVICE_URL } from "/js/config.js"; 15 + import { OG_CARD_SERVICE_URL, TENOR_GIF_PROXY_URL } from "/js/config.js"; 16 + import { isSafari } from "/js/utils.js"; 17 17 import "/js/components/lightbox-image-group.js"; 18 18 import "/js/components/streaming-video.js"; 19 19 import "/js/components/gif-player.js"; ··· 262 262 </div>`; 263 263 } 264 264 265 + function isTenorGifUrl(url) { 266 + try { 267 + const parsedUrl = new URL(url); 268 + if (parsedUrl.hostname !== "media.tenor.com") return false; 269 + const [_, id, filename] = parsedUrl.pathname.split("/"); 270 + return Boolean(id && filename && id.includes("AAAAC")); 271 + } catch { 272 + return false; 273 + } 274 + } 275 + 276 + // https://github.com/bluesky-social/social-app/blob/main/src/lib/strings/embed-player.ts 277 + function getTenorGifPlayerUri(url) { 278 + const parsedUrl = new URL(url); 279 + let [_, id, filename] = parsedUrl.pathname.split("/"); 280 + if (isSafari()) { 281 + id = id.replace("AAAAC", "AAAP1"); 282 + filename = filename.replace(".gif", ".mp4"); 283 + } else { 284 + id = id.replace("AAAAC", "AAAP3"); 285 + filename = filename.replace(".gif", ".webm"); 286 + } 287 + return `${TENOR_GIF_PROXY_URL}/${id}/${filename}`; 288 + } 289 + 265 290 function externalTemplate({ external, lazyLoadImages }) { 266 - const embedPlayer = parseEmbedPlayerFromUrl(external.uri); 267 - // todo: other embed players 268 - if (embedPlayer && embedPlayer.type === "tenor_gif") { 291 + if (isTenorGifUrl(external.uri)) { 269 292 return tenorPlayerTemplate({ 270 - uri: embedPlayer.playerUri, 293 + uri: getTenorGifPlayerUri(external.uri), 271 294 alt: external.description, 272 - lazyLoad: lazyLoadImages, 273 295 }); 274 296 } 275 297 return externalLinkTemplate({
+2
src/js/utils.js
··· 22 22 23 23 export const isDev = () => window.location.hostname === "localhost"; 24 24 export const isNative = () => Capacitor.isNativePlatform(); 25 + export const isSafari = () => 26 + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 25 27 26 28 export function sortBy(array, fnOrKey, { direction = "asc" } = {}) { 27 29 let fn = fnOrKey;