Skip to content

Commit

Permalink
feat(post): add client-side thumbnail fetching for webpage links from…
Browse files Browse the repository at this point in the history
… sites with CORS access
  • Loading branch information
plebeius-eth committed Oct 12, 2024
1 parent 5da4c02 commit b3bbb9c
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 6 deletions.
18 changes: 15 additions & 3 deletions src/components/post/post.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import styles from './post.module.css';
import { Link, useLocation, useParams } from 'react-router-dom';
import { Comment, useAuthorAddress, useBlock, useComment, useEditedComment, useSubplebbit, useSubscribe } from '@plebbit/plebbit-react-hooks';
import { useTranslation } from 'react-i18next';
import { isAllView, isPostView, isProfileHiddenView, isSubplebbitView } from '../../lib/utils/view-utils';
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { fetchWebpageThumbnailIfNeeded, getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { getHostname } from '../../lib/utils/url-utils';
import { getFormattedTimeAgo, formatLocalizedUTCTimestamp } from '../../lib/utils/time-utils';
import CommentEditForm from '../comment-edit-form';
Expand Down Expand Up @@ -131,7 +131,19 @@ const Post = ({ index, post = {} }: PostProps) => {
const postScore = upvoteCount === 0 && downvoteCount === 0 ? '•' : upvoteCount - downvoteCount || '?';
const postTitle = (title?.length > 300 ? title?.slice(0, 300) + '...' : title) || (content?.length > 300 ? content?.slice(0, 300) + '...' : content);

const commentMediaInfo = getCommentMediaInfo(post);
// some sites have CORS access, so the thumbnail can be fetched client-side, which is helpful if subplebbit.settings.fetchThumbnailUrls is false
const initialCommentMediaInfo = useMemo(() => getCommentMediaInfo(post), [post]);
const [commentMediaInfo, setCommentMediaInfo] = useState(initialCommentMediaInfo);
const fetchThumbnail = useCallback(async () => {
if (initialCommentMediaInfo?.type === 'webpage' && !initialCommentMediaInfo.thumbnail) {
const newMediaInfo = await fetchWebpageThumbnailIfNeeded(initialCommentMediaInfo);
setCommentMediaInfo(newMediaInfo);
}
}, [initialCommentMediaInfo]);
useEffect(() => {
fetchThumbnail();
}, [fetchThumbnail]);

const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const linkUrl = getHostname(link);
const linkClass = `${isInPostView ? (link ? styles.externalLink : styles.internalLink) : styles.link} ${pinned ? styles.pinnedLink : ''}`;
Expand Down
20 changes: 17 additions & 3 deletions src/components/reply/reply.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { Comment, useAccountComment, useAuthorAddress, useAuthorAvatar, useBlock, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import { flattenCommentsPages } from '@plebbit/plebbit-react-hooks/dist/lib/utils';
import { Link, useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import styles from './reply.module.css';
import useReplies from '../../hooks/use-replies';
import { CommentMediaInfo, getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { CommentMediaInfo, fetchWebpageThumbnailIfNeeded, getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { formatLocalizedUTCTimestamp, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import CommentEditForm from '../comment-edit-form';
import LoadingEllipsis from '../loading-ellipsis/';
Expand Down Expand Up @@ -295,7 +295,21 @@ const Reply = ({ cidOfReplyWithContext, depth = 0, isSingleComment, isSingleRepl
const showCommentEditForm = () => setIsEditing(true);
const hideCommentEditForm = () => setIsEditing(false);

const commentMediaInfo = getCommentMediaInfo(reply);
// some sites have CORS access, so the thumbnail can be fetched client-side, which is helpful if subplebbit.settings.fetchThumbnailUrls is false
const initialCommentMediaInfo = useMemo(() => getCommentMediaInfo(reply), [reply]);
const [commentMediaInfo, setCommentMediaInfo] = useState(initialCommentMediaInfo);

const fetchThumbnail = useCallback(async () => {
if (initialCommentMediaInfo?.type === 'webpage' && !initialCommentMediaInfo.thumbnail) {
const newMediaInfo = await fetchWebpageThumbnailIfNeeded(initialCommentMediaInfo);
setCommentMediaInfo(newMediaInfo);
}
}, [initialCommentMediaInfo]);

useEffect(() => {
fetchThumbnail();
}, [fetchThumbnail]);

const hasThumbnail = getHasThumbnail(commentMediaInfo, link);

const { t, i18n } = useTranslation();
Expand Down
54 changes: 54 additions & 0 deletions src/lib/utils/media-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,57 @@ export const getCommentMediaInfo = (comment: Comment): CommentMediaInfo | undefi
}
return;
};

// some sites have CORS access, so the thumbnail can be fetched client-side, which is helpful if subplebbit.settings.fetchThumbnailUrls is false
const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> => {
try {
const response = await fetch(url);
const html = await response.text();
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;
}
};

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));
};

0 comments on commit b3bbb9c

Please sign in to comment.