2024-03-22 16:15:38 +01:00
import { Comment } from '@plebbit/plebbit-react-hooks' ;
import extName from 'ext-name' ;
import { canEmbed } from '../../components/embed' ;
import memoize from 'memoizee' ;
2024-03-26 13:17:55 +01:00
import { isValidURL } from './url-utils' ;
2024-10-15 17:39:02 +02:00
import { Capacitor } from '@capacitor/core' ;
import { CapacitorHttp } from '@capacitor/core' ;
2024-03-22 16:15:38 +01:00
export interface CommentMediaInfo {
url : string ;
type : string ;
thumbnail? : string ;
patternThumbnailUrl? : string ;
2024-09-07 14:11:33 +02:00
post? : Comment ;
2024-03-22 16:15:38 +01:00
}
2024-04-28 17:40:05 +02:00
export const getDisplayMediaInfoType = ( type : string , t : any ) => {
switch ( type ) {
case 'image' :
return t ( 'image' );
2024-07-20 16:25:20 +02:00
case 'animated gif' :
return t ( 'animated_gif' );
case 'static gif' :
2024-09-03 14:27:29 +02:00
return t ( 'gif' );
2024-04-28 17:40:05 +02:00
case 'iframe' :
return t ( 'iframe' );
case 'video' :
return t ( 'video' );
case 'audio' :
return t ( 'audio' );
default :
return t ( 'webpage' );
}
};
2024-03-22 16:15:38 +01:00
export const getHasThumbnail = ( commentMediaInfo : CommentMediaInfo | undefined , link : string | undefined ) : boolean => {
const iframeThumbnail = commentMediaInfo ? . patternThumbnailUrl || commentMediaInfo ? . thumbnail ;
return link &&
commentMediaInfo &&
( commentMediaInfo . type === 'image' ||
commentMediaInfo . type === 'video' ||
2024-04-08 19:34:09 +02:00
commentMediaInfo . type === 'audio' ||
2024-03-26 13:17:55 +01:00
commentMediaInfo . type === 'gif' ||
2024-03-22 16:15:38 +01:00
( commentMediaInfo . type === 'webpage' && commentMediaInfo . thumbnail ) ||
( commentMediaInfo . type === 'iframe' && iframeThumbnail ))
? true
: false ;
};
2024-03-26 13:17:55 +01:00
const getYouTubeVideoId = ( url : URL ) : string | null => {
if ( url . host . includes ( 'youtu.be' )) {
return url . pathname . slice ( 1 );
} else if ( url . searchParams . has ( 'v' )) {
return url . searchParams . get ( 'v' );
}
return null ;
};
const getPatternThumbnailUrl = ( url : URL ) : string | undefined => {
const videoId = getYouTubeVideoId ( url );
if ( videoId ) {
return `https://img.youtube.com/vi/ ${ videoId } /0.jpg` ;
}
if ( url . host . includes ( 'streamable.com' )) {
const videoId = url . pathname . split ( '/' )[ 1 ];
return `https://cdn-cf-east.streamable.com/image/ ${ videoId } .jpg` ;
}
};
2024-04-24 08:48:31 +02:00
export const getLinkMediaInfo = memoize (
2024-04-10 12:19:02 +02:00
( link : string ) : CommentMediaInfo | undefined => {
if ( ! isValidURL ( link )) {
return ;
}
const url = new URL ( link );
let patternThumbnailUrl : string | undefined ;
let type : string = 'webpage' ;
let mime : string | undefined ;
2024-03-26 13:17:55 +01:00
2024-09-17 18:01:11 +02:00
if ( url . pathname === '/_next/image' && url . search . startsWith ( '?url=' )) {
return { url : link , type : 'image' };
}
2024-10-15 17:51:01 +02:00
// Non-direct imgbb links can return lower res thumbnails on web. On native, the full image can be fetched later.
if ( url . host === 'ibb.co' && ! Capacitor . isNativePlatform ()) {
const imageId = url . pathname . split ( '/' )[ 1 ];
return { url : link , type : 'webpage' , thumbnail : `https://i.ibb.co/ ${ imageId } /thumbnail.jpg` };
}
2024-04-10 12:19:02 +02:00
try {
2024-10-12 17:10:13 +02:00
mime = extName ( new URL ( link ). pathname . toLowerCase (). replace ( '/' , '' ))[ 0 ] ? . mime ;
if ( mime ) {
if ( mime . startsWith ( 'image' )) {
type = mime === 'image/gif' ? 'gif' : 'image' ;
} else if ( mime . startsWith ( 'video' )) {
type = 'video' ;
} else if ( mime . startsWith ( 'audio' )) {
type = 'audio' ;
2024-04-10 12:19:02 +02:00
}
2024-03-26 13:17:55 +01:00
}
2024-04-10 12:19:02 +02:00
2024-10-12 17:10:13 +02:00
if ( ! url . pathname . includes ( '.' )) {
type = 'webpage' ;
}
2024-04-10 12:19:02 +02:00
if ( canEmbed ( url ) || url . host . startsWith ( 'yt.' )) {
type = 'iframe' ;
patternThumbnailUrl = getPatternThumbnailUrl ( url );
}
} catch ( e ) {
console . error ( e );
2024-03-26 13:17:55 +01:00
}
2024-04-10 12:19:02 +02:00
return { url : link , type , patternThumbnailUrl };
},
{ max : 1000 },
);
2024-03-26 13:17:55 +01:00
2024-10-11 21:38:16 +02:00
const fetchWebpageThumbnail = async ( url : string ) : Promise < string | undefined > => {
try {
2024-10-15 17:39:02 +02:00
let html : string ;
2024-10-15 22:10:03 +02:00
const MAX_HTML_SIZE = 1024 * 1024 ;
const TIMEOUT = 5000 ;
2024-10-15 17:39:02 +02:00
if ( Capacitor . isNativePlatform ()) {
// in the native app, the Capacitor HTTP plugin is used to fetch the thumbnail
2024-10-15 22:10:03 +02:00
const response = await CapacitorHttp . get ({
url ,
readTimeout : TIMEOUT ,
connectTimeout : TIMEOUT ,
responseType : 'text' ,
headers : { Accept : 'text/html' , Range : `bytes=0- ${ MAX_HTML_SIZE - 1 } ` },
});
html = response . data . slice ( 0 , MAX_HTML_SIZE );
2024-10-15 17:39:02 +02:00
} else {
// some sites have CORS access, from which the thumbnail can be fetched client-side, which is helpful if subplebbit.settings.fetchThumbnailUrls is false
2024-10-15 22:10:03 +02:00
const controller = new AbortController ();
const timeoutId = setTimeout (() => controller . abort (), TIMEOUT );
const response = await fetch ( url , {
signal : controller.signal ,
headers : { Accept : 'text/html' },
});
clearTimeout ( timeoutId );
if ( ! response . ok ) throw new Error ( 'Network response was not ok' );
const reader = response . body ? . getReader ();
let result = '' ;
while ( true ) {
const { done , value } = await reader ! . read ();
if ( done || result . length >= MAX_HTML_SIZE ) break ;
result += new TextDecoder (). decode ( value );
}
html = result . slice ( 0 , MAX_HTML_SIZE );
2024-10-15 17:39:02 +02:00
}
2024-10-11 21:38:16 +02:00
const parser = new DOMParser ();
const doc = parser . parseFromString ( html , 'text/html' );
// Try to find Open Graph image
const ogImage = doc . querySelector ( 'meta[property="og:image"]' );
if ( ogImage && ogImage . getAttribute ( 'content' )) {
return ogImage . getAttribute ( 'content' ) ! ;
}
// If no Open Graph image, try to find the first image
const firstImage = doc . querySelector ( 'img' );
if ( firstImage && firstImage . getAttribute ( 'src' )) {
return new URL ( firstImage . getAttribute ( 'src' ) ! , url ). href ;
}
return undefined ;
} catch ( error ) {
console . error ( 'Error fetching webpage thumbnail:' , error );
return undefined ;
}
};
2024-04-10 12:19:02 +02:00
export const getCommentMediaInfo = ( comment : Comment ) : CommentMediaInfo | undefined => {
2024-03-22 16:15:38 +01:00
if ( ! comment ? . thumbnailUrl && ! comment ? . link ) {
return ;
}
2024-03-26 13:17:55 +01:00
const linkInfo = comment . link ? getLinkMediaInfo ( comment . link ) : undefined ;
if ( linkInfo ) {
2024-09-07 14:11:33 +02:00
return {
... linkInfo ,
thumbnail : comment.thumbnailUrl || linkInfo . thumbnail ,
post : comment ,
};
2024-03-22 16:15:38 +01:00
}
2024-03-26 13:17:55 +01:00
return ;
2024-03-22 16:15:38 +01:00
};
2024-09-07 14:11:33 +02:00
export const getMediaDimensions = ( commentMediaInfo : CommentMediaInfo | undefined ) : string => {
if ( ! commentMediaInfo ) return '' ;
const { type , url , post } = commentMediaInfo ;
if ( type === 'iframe' && url ) {
const embedUrl = new URL ( url );
if ( canEmbed ( embedUrl )) {
// hardcoded dimensions from embed.module.css
if ( embedUrl . hostname . includes ( 'youtube.com' ) || embedUrl . hostname . includes ( 'youtu.be' )) {
return '800x450' ;
} else if ( embedUrl . hostname . includes ( 'instagram.com' )) {
return '360x420' ;
} else if ( embedUrl . hostname . includes ( 'reddit.com' )) {
return '500x520' ;
} else if ( embedUrl . hostname . includes ( 'tiktok.com' )) {
return '400x780' ;
} else if ( embedUrl . hostname . includes ( 'x.com' ) || embedUrl . hostname . includes ( 'twitter.com' )) {
return '550x580' ;
} else if ( embedUrl . hostname . includes ( 'soundcloud.com' )) {
return '700x166' ;
}
}
} else if ( type === 'audio' ) {
return '700x240' ; // hardcoded dimensions from embed.module.css
} else if ( type === 'image' || type === 'video' || type === 'gif' ) {
// media dimensions calculated by API
if ( post ? . linkWidth && post ? . linkHeight ) {
return ` ${ post . linkWidth } x ${ post . linkHeight } ` ;
}
}
return '' ;
};
2024-10-13 12:58:29 +02:00
export const fetchWebpageThumbnailIfNeeded = async ( commentMediaInfo : CommentMediaInfo ) : Promise < CommentMediaInfo > => {
if ( commentMediaInfo . type === 'webpage' && ! commentMediaInfo . thumbnail ) {
const cachedThumbnail = getCachedThumbnail ( commentMediaInfo . url );
if ( cachedThumbnail ) {
return { ... commentMediaInfo , thumbnail : cachedThumbnail };
}
const thumbnail = await fetchWebpageThumbnail ( commentMediaInfo . url );
if ( thumbnail ) {
setCachedThumbnail ( commentMediaInfo . url , thumbnail );
}
return { ... commentMediaInfo , thumbnail };
}
return commentMediaInfo ;
};
const THUMBNAIL_CACHE_KEY = 'webpageThumbnailCache' ;
export const getCachedThumbnail = ( url : string ) : string | null => {
const cache = JSON . parse ( localStorage . getItem ( THUMBNAIL_CACHE_KEY ) || '{}' );
return cache [ url ] || null ;
};
export const setCachedThumbnail = ( url : string , thumbnail : string ) : void => {
const cache = JSON . parse ( localStorage . getItem ( THUMBNAIL_CACHE_KEY ) || '{}' );
cache [ url ] = thumbnail ;
localStorage . setItem ( THUMBNAIL_CACHE_KEY , JSON . stringify ( cache ));
};