Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import GroupDetail from "./pages/GroupDetail";
import Profile from "./pages/Profile";
import Hashtag from "./pages/Hashtag";
import Trending from "./pages/Trending";
import GroupPostsFeed from "./pages/GroupPostsFeed";

// Lazy load less frequently used pages
const NotFound = lazy(() => import("./pages/NotFound"));
Expand Down Expand Up @@ -47,6 +48,7 @@ export function AppRouter() {
<Route path="/profile/:pubkey" element={<Profile />} />
<Route path="/t/:hashtag" element={<Hashtag />} />
<Route path="/trending" element={<Trending />} />
<Route path="/feed" element={<GroupPostsFeed />} />

{/* Lazy loaded routes */}
<Route path="/group/:groupId/settings" element={
Expand Down Expand Up @@ -109,4 +111,4 @@ export function AppRouter() {
</BrowserRouter>
);
}
export default AppRouter;
export default AppRouter;
68 changes: 56 additions & 12 deletions src/components/ImagePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
import { AlertCircle, ImageOff } from 'lucide-react';

interface ImagePreviewProps {
src: string;
Expand All @@ -13,6 +13,7 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [retryCount, setRetryCount] = useState(0);

// Process and normalize the URL
useEffect(() => {
Expand Down Expand Up @@ -43,8 +44,14 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp
// Remove size parameters for Twitter images
url = url.replace(/&name=[^&]+/, '');
}

// 4. Handle Discord CDN URLs
if (url.includes('cdn.discordapp.com/attachments')) {
// Add cache-busting parameter for Discord images
url = `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`;
}

// 4. Handle URLs with unescaped characters
// 5. Handle URLs with unescaped characters
if (url.includes(' ')) {
url = url.replace(/ /g, '%20');
}
Expand All @@ -53,6 +60,7 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp
setImageUrl(url);
setIsLoading(true);
setHasError(false);
setRetryCount(0);

} catch (error) {
console.error('Error processing image URL:', src, error);
Expand All @@ -65,23 +73,59 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp
};

const handleError = () => {
console.error('Failed to load image:', imageUrl, 'Original URL:', src);
setIsLoading(false);
setHasError(true);
console.error(`Failed to load image (attempt ${retryCount + 1}):`, imageUrl, 'Original URL:', src);

// Max retry attempts
if (retryCount >= 2) {
setIsLoading(false);
setHasError(true);
return;
}

// Increment retry counter
setRetryCount(prev => prev + 1);

// Try alternative URL formats if the original fails
if (!imageUrl.includes('?format=')) {
// Some services support format parameter
const newUrl = `${imageUrl}?format=jpg`;
console.log('Trying alternative URL format:', newUrl);
// Try alternative formats based on retry count
if (retryCount === 0) {
// First retry: Try different format
if (imageUrl.includes('.png')) {
// Try jpg instead
const newUrl = imageUrl.replace('.png', '.jpg');
console.log('Trying JPG format:', newUrl);
setImageUrl(newUrl);
setIsLoading(true);
} else if (imageUrl.includes('.jpg') || imageUrl.includes('.jpeg')) {
// Try png instead
const newUrl = imageUrl.replace(/\.(jpg|jpeg)/, '.png');
console.log('Trying PNG format:', newUrl);
setImageUrl(newUrl);
setIsLoading(true);
} else {
// Add format parameter
const newUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}format=jpg`;
console.log('Trying with format parameter:', newUrl);
setImageUrl(newUrl);
setIsLoading(true);
}
} else if (retryCount === 1) {
// Second retry: Try with cache busting parameter
const cacheBuster = Date.now();
const newUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_=${cacheBuster}`;
console.log('Trying with cache buster:', newUrl);
setImageUrl(newUrl);
setIsLoading(true);
setHasError(false);
}
};

if (!imageUrl || (hasError && !isLoading)) {
return null;
return (
<div className={cn("flex items-center justify-center bg-muted/20 rounded-md my-2 h-32", className)}>
<div className="flex flex-col items-center text-muted-foreground">
<ImageOff size={24} className="mb-2" />
<span className="text-xs">Image unavailable</span>
</div>
</div>
);
}

return (
Expand Down
171 changes: 139 additions & 32 deletions src/components/LinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, Link, Image } from 'lucide-react';

interface LinkPreviewProps {
url: string;
Expand All @@ -14,32 +14,99 @@ interface LinkMetadata {
domain: string;
}

// Function to extract domain name from a URL
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace('www.', '');
} catch (error) {
// If URL parsing fails, use a regex fallback
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?]+)/i);
return match ? match[1] : url;
}
};

export function LinkPreview({ url }: LinkPreviewProps) {
const [metadata, setMetadata] = useState<LinkMetadata | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [fetchTries, setFetchTries] = useState(0);

// Clean and format the URL for display
const displayUrl = url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
const domain = extractDomain(url);

useEffect(() => {
const fetchMetadata = async () => {
try {
// Reset state for new URL
if (fetchTries === 0) {
setLoading(true);
setError(false);
}

// Use a proxy service to avoid CORS issues
// In a production app, you would use your own backend proxy or a service like Microlink
const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
try {
// Handle special case domains directly
if (
url.includes('youtube.com') ||
url.includes('youtu.be') ||
url.includes('twitter.com') ||
url.includes('x.com')
) {
// For these domains, just display a simplified preview without trying to fetch metadata
setMetadata({
title: url.includes('youtube') ? 'YouTube Video' : 'Twitter Post',
description: '',
image: '',
domain: domain
});
setLoading(false);
return;
}

// Try different proxy services based on retry count
let proxyUrl = '';

// On first try, use allorigins
if (fetchTries === 0) {
proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
}
// On second try, use another service
else if (fetchTries === 1) {
proxyUrl = `https://cors-anywhere.herokuapp.com/${url}`;
}
// On third try, give up on proxies and just show a clean preview
else {
throw new Error('All proxy attempts failed');
}

// Add a timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout

const response = await fetch(proxyUrl, {
signal: controller.signal
});

clearTimeout(timeoutId);

if (!response.ok) {
throw new Error('Failed to fetch link metadata');
throw new Error(`Response not OK: ${response.status}`);
}

const data = await response.json();
const html = data.contents;
let html = '';
let doc: Document;

// Create a DOM parser to extract metadata
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Parse the response based on the proxy used
if (fetchTries === 0) {
const data = await response.json();
html = data.contents;
const parser = new DOMParser();
doc = parser.parseFromString(html, 'text/html');
} else {
html = await response.text();
const parser = new DOMParser();
doc = parser.parseFromString(html, 'text/html');
}

// Extract metadata from Open Graph tags, Twitter cards, or regular meta tags
const title =
Expand All @@ -59,30 +126,42 @@ export function LinkPreview({ url }: LinkPreviewProps) {
doc.querySelector('meta[name="twitter:image"]')?.getAttribute('content') ||
'';

// Extract domain from URL
const urlObj = new URL(url);
const domain = urlObj.hostname.replace('www.', '');

setMetadata({
title,
title: title || url,
description,
image,
domain
});
setLoading(false);
} catch (err) {
console.error('Error fetching link preview:', err);
setError(true);
} finally {
setLoading(false);

// If we haven't exceeded max retries, try another method
if (fetchTries < 2) {
setFetchTries(prev => prev + 1);
} else {
// After all retries fail, show fallback
setError(true);
setLoading(false);

// Still provide basic metadata for fallback display
setMetadata({
title: '',
description: '',
image: '',
domain
});
}
}
};

if (url) {
fetchMetadata();
}
}, [url]);
}, [url, fetchTries, domain]);

if (loading) {
// Show loading state only on first attempt
if (loading && fetchTries === 0) {
return (
<Card className="overflow-hidden mt-2 max-w-md">
<CardContent className="p-0">
Expand All @@ -101,21 +180,43 @@ export function LinkPreview({ url }: LinkPreviewProps) {
);
}

if (error || !metadata) {
// Fallback to a simple link display
// Show fallback for errors or when all retries failed
if (error || (loading && fetchTries >= 2)) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center mt-1 px-3 py-2 bg-muted/30 rounded-md text-sm text-primary hover:bg-muted/50 transition-colors"
>
{url.includes('youtube.com') || url.includes('youtu.be') ? (
<Image className="h-4 w-4 mr-2 text-red-500" />
) : url.includes('twitter.com') || url.includes('x.com') ? (
<Link className="h-4 w-4 mr-2 text-blue-400" />
) : (
<ExternalLink className="h-4 w-4 mr-2" />
)}
<span className="truncate max-w-[250px]">{displayUrl}</span>
</a>
);
}

// If metadata has no title but we're not in an error state, show a simplified preview
if (metadata && !metadata.title) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline flex items-center mt-1 text-sm"
className="inline-flex items-center mt-1 px-3 py-2 bg-muted/30 rounded-md text-sm text-primary hover:bg-muted/50 transition-colors"
>
<ExternalLink className="h-3.5 w-3.5 mr-1" />
{url}
<ExternalLink className="h-4 w-4 mr-2" />
<span className="truncate max-w-[250px]">{displayUrl}</span>
</a>
);
}

// Full link preview card with metadata
return (
<a
href={url}
Expand All @@ -126,20 +227,26 @@ export function LinkPreview({ url }: LinkPreviewProps) {
<Card className="overflow-hidden border-muted">
<CardContent className="p-0">
<div className="flex flex-col sm:flex-row">
{metadata.image && (
{metadata?.image && (
<div className="sm:w-1/3 h-32 sm:h-auto">
<div
className="w-full h-full bg-cover bg-center"
style={{ backgroundImage: `url(${metadata.image})` }}
onError={(e) => {
// Hide the image div if it fails to load
(e.target as HTMLDivElement).style.display = 'none';
}}
/>
</div>
)}
<div className={`${metadata.image ? 'sm:w-2/3' : 'w-full'} p-3 space-y-1`}>
<h3 className="font-medium text-sm line-clamp-2">{metadata.title}</h3>
<p className="text-xs text-muted-foreground line-clamp-2">{metadata.description}</p>
<div className={`${metadata?.image ? 'sm:w-2/3' : 'w-full'} p-3 space-y-1`}>
<h3 className="font-medium text-sm line-clamp-2">{metadata?.title}</h3>
{metadata?.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{metadata.description}</p>
)}
<div className="flex items-center text-xs text-muted-foreground pt-1">
<ExternalLink className="h-3 w-3 mr-1" />
{metadata.domain}
{metadata?.domain}
</div>
</div>
</div>
Expand Down
Loading