This repository has no description
0

Configure Feed

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

semble / src / webapp / lib / utils / link.ts
8.5 kB 290 lines
1export const getDomain = (url: string) => { 2 try { 3 return new URL(url).hostname; 4 } catch { 5 return url; 6 } 7}; 8 9export const getUrlFromSlug = (slug: string[]) => { 10 const decoded = slug.map(decodeURIComponent); 11 const url = decoded.join('/'); 12 13 // only normalize if the scheme has a single slash after it (i.e. malformed) 14 const normalizedUrl = /^([a-zA-Z]+:)\/[^/]/.test(url) 15 ? url.replace(/^([a-zA-Z]+:)\//, '$1//') 16 : url; 17 18 return normalizedUrl; 19}; 20 21const getAppOrigin = () => { 22 // Use the configured app URL if available, otherwise fall back to current origin 23 const appUrl = process.env.NEXT_PUBLIC_APP_URL; 24 if (appUrl) { 25 try { 26 return new URL(appUrl).origin; 27 } catch { 28 // invalid app URL, fall back to current origin 29 } 30 } 31 return window.location.origin; 32}; 33 34const isSembleUrl = (url: string): boolean => { 35 // Relative paths are always local to the app 36 if (url.startsWith('/')) return true; 37 try { 38 const parsedUrl = new URL(url); 39 return parsedUrl.origin === getAppOrigin(); 40 } catch { 41 return false; 42 } 43}; 44 45export const isCollectionPage = (url: string) => { 46 try { 47 if (!isSembleUrl(url)) return false; 48 const pathname = url.startsWith('/') ? url : new URL(url).pathname; 49 // expect /profile/:handle/collections/:id 50 const pattern = /^\/profile\/[^/]+\/collections\/[^/]+\/?$/; 51 return pattern.test(pathname); 52 } catch { 53 // invalid URL 54 return false; 55 } 56}; 57 58export const isProfilePage = (url: string) => { 59 try { 60 if (!isSembleUrl(url)) return false; 61 const pathname = url.startsWith('/') ? url : new URL(url).pathname; 62 // matches /profile/:handle and any subroutes (e.g. /profile/:handle/likes) 63 const pattern = /^\/profile\/[^/]+/; 64 return pattern.test(pathname); 65 } catch { 66 // invalid URL 67 return false; 68 } 69}; 70 71export enum SupportedPlatform { 72 BLUESKY_POST = 'bluesky post', 73 BLACKSKY_POST = 'blacksky post', 74 SEMBLE_COLLECTION = 'semble collection', 75 SEMBLE_PROFILE = 'semble profile', 76 SPOTIFY = 'spotify', 77 PLYRFM_TRACK = 'plyr.fm', 78 YOUTUBE_VIDEO = 'youtube video', 79 BANDCAMP_ALBUM = 'bandcamp album', 80 BANDCAMP_TRACK = 'bandcamp track', 81 SOUNDCLOUD_TRACK = 'soundcloud track', 82 SOUNDCLOUD_SET = 'soundcloud set', 83 DEFAULT = 'default', 84} 85 86type PlatformData = 87 | { 88 type: SupportedPlatform.SEMBLE_COLLECTION; 89 url: string; 90 handle: string; 91 rkey: string; 92 } 93 | { 94 type: SupportedPlatform.SEMBLE_PROFILE; 95 url: string; 96 handle: string; 97 } 98 | { 99 type: Exclude< 100 SupportedPlatform, 101 SupportedPlatform.SEMBLE_COLLECTION | SupportedPlatform.SEMBLE_PROFILE 102 >; 103 url: string; 104 }; 105 106export const detectUrlPlatform = (url: string): PlatformData => { 107 if (isCollectionPage(url)) { 108 try { 109 const pathname = url.startsWith('/') ? url : new URL(url).pathname; 110 const pathParts = pathname.split('/').filter(Boolean); 111 // expected format: profile/:handle/collections/:rkey 112 const handle = pathParts[1]; 113 const rkey = pathParts[3]; 114 return { type: SupportedPlatform.SEMBLE_COLLECTION, url, handle, rkey }; 115 } catch { 116 return { 117 type: SupportedPlatform.SEMBLE_COLLECTION, 118 url, 119 handle: '', 120 rkey: '', 121 }; 122 } 123 } 124 125 if (isProfilePage(url)) { 126 try { 127 const pathname = url.startsWith('/') ? url : new URL(url).pathname; 128 const pathParts = pathname.split('/').filter(Boolean); 129 // expected format: profile/:handle or profile/:handle/:subroute 130 const handle = pathParts[1]; 131 return { type: SupportedPlatform.SEMBLE_PROFILE, url, handle }; 132 } catch { 133 return { type: SupportedPlatform.DEFAULT, url }; 134 } 135 } 136 137 try { 138 const parsedUrl = new URL(url); 139 140 // bluesky posts 141 // https://bsky.app/profile/handle/post/id 142 if ( 143 parsedUrl.hostname === 'bsky.app' && 144 parsedUrl.pathname.includes('/post/') 145 ) { 146 return { type: SupportedPlatform.BLUESKY_POST, url }; 147 } 148 149 // blacksky posts 150 // https://blacksky.community/profile/handle/post/id 151 if ( 152 parsedUrl.hostname === 'blacksky.community' && 153 parsedUrl.pathname.includes('/post/') 154 ) { 155 return { type: SupportedPlatform.BLACKSKY_POST, url }; 156 } 157 158 // youtube 159 if (parsedUrl.hostname === 'youtu.be') { 160 const videoId = parsedUrl.pathname.split('/')[1]; 161 const t = parsedUrl.searchParams.get('t') ?? '0'; 162 const seek = encodeURIComponent(t.replace(/s$/, '')); 163 164 if (videoId) { 165 return { 166 type: SupportedPlatform.YOUTUBE_VIDEO, 167 url: `https://www.youtube.com/embed/${videoId}?start=${seek}`, 168 }; 169 } 170 } 171 172 // youtube 173 if ( 174 parsedUrl.hostname === 'www.youtube.com' || 175 parsedUrl.hostname === 'youtube.com' || 176 parsedUrl.hostname === 'm.youtube.com' || 177 parsedUrl.hostname === 'music.youtube.com' 178 ) { 179 const [__, page, shortOrLiveVideoId] = parsedUrl.pathname.split('/'); 180 181 const isShorts = page === 'shorts'; 182 const isLive = page === 'live'; 183 const videoId = 184 isShorts || isLive 185 ? shortOrLiveVideoId 186 : (parsedUrl.searchParams.get('v') as string); 187 const t = parsedUrl.searchParams.get('t') ?? '0'; 188 const seek = encodeURIComponent(t.replace(/s$/, '')); 189 190 return { 191 type: SupportedPlatform.YOUTUBE_VIDEO, 192 url: `https://www.youtube.com/embed/${videoId}?start=${seek}`, 193 }; 194 } 195 196 // spotify 197 if (parsedUrl.hostname === 'open.spotify.com') { 198 const [__, typeOrLocale, idOrType, id] = parsedUrl.pathname.split('/'); 199 200 if (typeOrLocale === 'album' || idOrType === 'album') { 201 return { 202 type: SupportedPlatform.SPOTIFY, 203 url: `https://open.spotify.com/embed/album/${id ?? idOrType}`, 204 }; 205 } 206 if (typeOrLocale === 'track' || idOrType === 'track') { 207 return { 208 type: SupportedPlatform.SPOTIFY, 209 url: `https://open.spotify.com/embed/track/${id ?? idOrType}`, 210 }; 211 } 212 if (typeOrLocale === 'episode' || idOrType === 'episode') { 213 return { 214 type: SupportedPlatform.SPOTIFY, 215 url: `https://open.spotify.com/embed/episode/${id ?? idOrType}`, 216 }; 217 } 218 if (typeOrLocale === 'show' || idOrType === 'show') { 219 return { 220 type: SupportedPlatform.SPOTIFY, 221 url: `https://open.spotify.com/embed/show/${id ?? idOrType}`, 222 }; 223 } 224 } 225 226 // plyr.fm 227 if ( 228 parsedUrl.hostname === 'plyr.fm' || 229 parsedUrl.hostname === 'www.plyr.fm' 230 ) { 231 const [__, type, id] = parsedUrl.pathname.split('/'); 232 233 if (type === 'track' && id) { 234 return { 235 type: SupportedPlatform.PLYRFM_TRACK, 236 url: `https://plyr.fm/embed/track/${id}`, 237 }; 238 } 239 } 240 241 // bandcamp 242 const bandcampRegex = /^[a-z\d][a-z\d-]{2,}[a-z\d]\.bandcamp\.com$/i; 243 if (bandcampRegex.test(parsedUrl.hostname)) { 244 const pathComponents = parsedUrl.pathname.split('/'); 245 switch (pathComponents[1]) { 246 case 'album': 247 return { 248 type: SupportedPlatform.BANDCAMP_ALBUM, 249 url: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent( 250 parsedUrl.href, 251 )}/size=medium/minimal=true/transparent=true/`, 252 }; 253 case 'track': 254 return { 255 type: SupportedPlatform.BANDCAMP_TRACK, 256 url: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent( 257 parsedUrl.href, 258 )}/size=medium/minimal=true/transparent=true/`, 259 }; 260 } 261 } 262 263 // soundcloud 264 if ( 265 parsedUrl.hostname === 'soundcloud.com' || 266 parsedUrl.hostname === 'www.soundcloud.com' 267 ) { 268 const [, user, trackOrSets, set] = parsedUrl.pathname.split('/'); 269 270 if (user && trackOrSets) { 271 if (trackOrSets === 'sets' && set) { 272 return { 273 type: SupportedPlatform.SOUNDCLOUD_SET, 274 url: `https://w.soundcloud.com/player/?url=${url}&auto_play=false&visual=false&hide_related=true`, 275 }; 276 } 277 278 return { 279 type: SupportedPlatform.SOUNDCLOUD_TRACK, 280 url: `https://w.soundcloud.com/player/?url=${url}&auto_play=false&visual=false&hide_related=true`, 281 }; 282 } 283 } 284 285 return { type: SupportedPlatform.DEFAULT, url }; // no supported service detected 286 } catch (e) { 287 // invalid url 288 return { type: SupportedPlatform.DEFAULT, url }; 289 } 290};