Skip to content

Commit df4600d

Browse files
Merge pull request #397 from andotherstuff/feature/group-posts-feed
Feature: Show a feed of posts from your groups (Fixes #362) + mobile UI improvements and bug fixes
2 parents 89134d3 + 366ad6d commit df4600d

File tree

11 files changed

+1355
-205
lines changed

11 files changed

+1355
-205
lines changed

src/AppRouter.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import GroupDetail from "./pages/GroupDetail";
99
import Profile from "./pages/Profile";
1010
import Hashtag from "./pages/Hashtag";
1111
import Trending from "./pages/Trending";
12+
import GroupPostsFeed from "./pages/GroupPostsFeed";
1213

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

5153
{/* Lazy loaded routes */}
5254
<Route path="/group/:groupId/settings" element={
@@ -109,4 +111,4 @@ export function AppRouter() {
109111
</BrowserRouter>
110112
);
111113
}
112-
export default AppRouter;
114+
export default AppRouter;

src/components/ImagePreview.tsx

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect } from 'react';
22
import { cn } from '@/lib/utils';
33
import { Skeleton } from '@/components/ui/skeleton';
4-
import { AlertCircle } from 'lucide-react';
4+
import { AlertCircle, ImageOff } from 'lucide-react';
55

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

1718
// Process and normalize the URL
1819
useEffect(() => {
@@ -43,8 +44,14 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp
4344
// Remove size parameters for Twitter images
4445
url = url.replace(/&name=[^&]+/, '');
4546
}
47+
48+
// 4. Handle Discord CDN URLs
49+
if (url.includes('cdn.discordapp.com/attachments')) {
50+
// Add cache-busting parameter for Discord images
51+
url = `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`;
52+
}
4653

47-
// 4. Handle URLs with unescaped characters
54+
// 5. Handle URLs with unescaped characters
4855
if (url.includes(' ')) {
4956
url = url.replace(/ /g, '%20');
5057
}
@@ -53,6 +60,7 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp
5360
setImageUrl(url);
5461
setIsLoading(true);
5562
setHasError(false);
63+
setRetryCount(0);
5664

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

6775
const handleError = () => {
68-
console.error('Failed to load image:', imageUrl, 'Original URL:', src);
69-
setIsLoading(false);
70-
setHasError(true);
76+
console.error(`Failed to load image (attempt ${retryCount + 1}):`, imageUrl, 'Original URL:', src);
77+
78+
// Max retry attempts
79+
if (retryCount >= 2) {
80+
setIsLoading(false);
81+
setHasError(true);
82+
return;
83+
}
84+
85+
// Increment retry counter
86+
setRetryCount(prev => prev + 1);
7187

72-
// Try alternative URL formats if the original fails
73-
if (!imageUrl.includes('?format=')) {
74-
// Some services support format parameter
75-
const newUrl = `${imageUrl}?format=jpg`;
76-
console.log('Trying alternative URL format:', newUrl);
88+
// Try alternative formats based on retry count
89+
if (retryCount === 0) {
90+
// First retry: Try different format
91+
if (imageUrl.includes('.png')) {
92+
// Try jpg instead
93+
const newUrl = imageUrl.replace('.png', '.jpg');
94+
console.log('Trying JPG format:', newUrl);
95+
setImageUrl(newUrl);
96+
setIsLoading(true);
97+
} else if (imageUrl.includes('.jpg') || imageUrl.includes('.jpeg')) {
98+
// Try png instead
99+
const newUrl = imageUrl.replace(/\.(jpg|jpeg)/, '.png');
100+
console.log('Trying PNG format:', newUrl);
101+
setImageUrl(newUrl);
102+
setIsLoading(true);
103+
} else {
104+
// Add format parameter
105+
const newUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}format=jpg`;
106+
console.log('Trying with format parameter:', newUrl);
107+
setImageUrl(newUrl);
108+
setIsLoading(true);
109+
}
110+
} else if (retryCount === 1) {
111+
// Second retry: Try with cache busting parameter
112+
const cacheBuster = Date.now();
113+
const newUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_=${cacheBuster}`;
114+
console.log('Trying with cache buster:', newUrl);
77115
setImageUrl(newUrl);
78116
setIsLoading(true);
79-
setHasError(false);
80117
}
81118
};
82119

83120
if (!imageUrl || (hasError && !isLoading)) {
84-
return null;
121+
return (
122+
<div className={cn("flex items-center justify-center bg-muted/20 rounded-md my-2 h-32", className)}>
123+
<div className="flex flex-col items-center text-muted-foreground">
124+
<ImageOff size={24} className="mb-2" />
125+
<span className="text-xs">Image unavailable</span>
126+
</div>
127+
</div>
128+
);
85129
}
86130

87131
return (

src/components/LinkPreview.tsx

Lines changed: 139 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect } from 'react';
22
import { Card, CardContent } from '@/components/ui/card';
33
import { Skeleton } from '@/components/ui/skeleton';
4-
import { ExternalLink } from 'lucide-react';
4+
import { ExternalLink, Link, Image } from 'lucide-react';
55

66
interface LinkPreviewProps {
77
url: string;
@@ -14,32 +14,99 @@ interface LinkMetadata {
1414
domain: string;
1515
}
1616

17+
// Function to extract domain name from a URL
18+
const extractDomain = (url: string): string => {
19+
try {
20+
const urlObj = new URL(url);
21+
return urlObj.hostname.replace('www.', '');
22+
} catch (error) {
23+
// If URL parsing fails, use a regex fallback
24+
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?]+)/i);
25+
return match ? match[1] : url;
26+
}
27+
};
28+
1729
export function LinkPreview({ url }: LinkPreviewProps) {
1830
const [metadata, setMetadata] = useState<LinkMetadata | null>(null);
1931
const [loading, setLoading] = useState(true);
2032
const [error, setError] = useState(false);
33+
const [fetchTries, setFetchTries] = useState(0);
34+
35+
// Clean and format the URL for display
36+
const displayUrl = url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
37+
const domain = extractDomain(url);
2138

2239
useEffect(() => {
2340
const fetchMetadata = async () => {
24-
try {
41+
// Reset state for new URL
42+
if (fetchTries === 0) {
2543
setLoading(true);
2644
setError(false);
45+
}
2746

28-
// Use a proxy service to avoid CORS issues
29-
// In a production app, you would use your own backend proxy or a service like Microlink
30-
const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
31-
const response = await fetch(proxyUrl);
47+
try {
48+
// Handle special case domains directly
49+
if (
50+
url.includes('youtube.com') ||
51+
url.includes('youtu.be') ||
52+
url.includes('twitter.com') ||
53+
url.includes('x.com')
54+
) {
55+
// For these domains, just display a simplified preview without trying to fetch metadata
56+
setMetadata({
57+
title: url.includes('youtube') ? 'YouTube Video' : 'Twitter Post',
58+
description: '',
59+
image: '',
60+
domain: domain
61+
});
62+
setLoading(false);
63+
return;
64+
}
65+
66+
// Try different proxy services based on retry count
67+
let proxyUrl = '';
68+
69+
// On first try, use allorigins
70+
if (fetchTries === 0) {
71+
proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
72+
}
73+
// On second try, use another service
74+
else if (fetchTries === 1) {
75+
proxyUrl = `https://cors-anywhere.herokuapp.com/${url}`;
76+
}
77+
// On third try, give up on proxies and just show a clean preview
78+
else {
79+
throw new Error('All proxy attempts failed');
80+
}
81+
82+
// Add a timeout to prevent hanging requests
83+
const controller = new AbortController();
84+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
85+
86+
const response = await fetch(proxyUrl, {
87+
signal: controller.signal
88+
});
89+
90+
clearTimeout(timeoutId);
3291

3392
if (!response.ok) {
34-
throw new Error('Failed to fetch link metadata');
93+
throw new Error(`Response not OK: ${response.status}`);
3594
}
3695

37-
const data = await response.json();
38-
const html = data.contents;
96+
let html = '';
97+
let doc: Document;
3998

40-
// Create a DOM parser to extract metadata
41-
const parser = new DOMParser();
42-
const doc = parser.parseFromString(html, 'text/html');
99+
// Parse the response based on the proxy used
100+
if (fetchTries === 0) {
101+
const data = await response.json();
102+
html = data.contents;
103+
const parser = new DOMParser();
104+
doc = parser.parseFromString(html, 'text/html');
105+
} else {
106+
html = await response.text();
107+
const parser = new DOMParser();
108+
doc = parser.parseFromString(html, 'text/html');
109+
}
43110

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

62-
// Extract domain from URL
63-
const urlObj = new URL(url);
64-
const domain = urlObj.hostname.replace('www.', '');
65-
66129
setMetadata({
67-
title,
130+
title: title || url,
68131
description,
69132
image,
70133
domain
71134
});
135+
setLoading(false);
72136
} catch (err) {
73137
console.error('Error fetching link preview:', err);
74-
setError(true);
75-
} finally {
76-
setLoading(false);
138+
139+
// If we haven't exceeded max retries, try another method
140+
if (fetchTries < 2) {
141+
setFetchTries(prev => prev + 1);
142+
} else {
143+
// After all retries fail, show fallback
144+
setError(true);
145+
setLoading(false);
146+
147+
// Still provide basic metadata for fallback display
148+
setMetadata({
149+
title: '',
150+
description: '',
151+
image: '',
152+
domain
153+
});
154+
}
77155
}
78156
};
79157

80158
if (url) {
81159
fetchMetadata();
82160
}
83-
}, [url]);
161+
}, [url, fetchTries, domain]);
84162

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

104-
if (error || !metadata) {
105-
// Fallback to a simple link display
183+
// Show fallback for errors or when all retries failed
184+
if (error || (loading && fetchTries >= 2)) {
185+
return (
186+
<a
187+
href={url}
188+
target="_blank"
189+
rel="noopener noreferrer"
190+
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"
191+
>
192+
{url.includes('youtube.com') || url.includes('youtu.be') ? (
193+
<Image className="h-4 w-4 mr-2 text-red-500" />
194+
) : url.includes('twitter.com') || url.includes('x.com') ? (
195+
<Link className="h-4 w-4 mr-2 text-blue-400" />
196+
) : (
197+
<ExternalLink className="h-4 w-4 mr-2" />
198+
)}
199+
<span className="truncate max-w-[250px]">{displayUrl}</span>
200+
</a>
201+
);
202+
}
203+
204+
// If metadata has no title but we're not in an error state, show a simplified preview
205+
if (metadata && !metadata.title) {
106206
return (
107207
<a
108208
href={url}
109209
target="_blank"
110210
rel="noopener noreferrer"
111-
className="text-blue-500 hover:underline flex items-center mt-1 text-sm"
211+
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"
112212
>
113-
<ExternalLink className="h-3.5 w-3.5 mr-1" />
114-
{url}
213+
<ExternalLink className="h-4 w-4 mr-2" />
214+
<span className="truncate max-w-[250px]">{displayUrl}</span>
115215
</a>
116216
);
117217
}
118218

219+
// Full link preview card with metadata
119220
return (
120221
<a
121222
href={url}
@@ -126,20 +227,26 @@ export function LinkPreview({ url }: LinkPreviewProps) {
126227
<Card className="overflow-hidden border-muted">
127228
<CardContent className="p-0">
128229
<div className="flex flex-col sm:flex-row">
129-
{metadata.image && (
230+
{metadata?.image && (
130231
<div className="sm:w-1/3 h-32 sm:h-auto">
131232
<div
132233
className="w-full h-full bg-cover bg-center"
133234
style={{ backgroundImage: `url(${metadata.image})` }}
235+
onError={(e) => {
236+
// Hide the image div if it fails to load
237+
(e.target as HTMLDivElement).style.display = 'none';
238+
}}
134239
/>
135240
</div>
136241
)}
137-
<div className={`${metadata.image ? 'sm:w-2/3' : 'w-full'} p-3 space-y-1`}>
138-
<h3 className="font-medium text-sm line-clamp-2">{metadata.title}</h3>
139-
<p className="text-xs text-muted-foreground line-clamp-2">{metadata.description}</p>
242+
<div className={`${metadata?.image ? 'sm:w-2/3' : 'w-full'} p-3 space-y-1`}>
243+
<h3 className="font-medium text-sm line-clamp-2">{metadata?.title}</h3>
244+
{metadata?.description && (
245+
<p className="text-xs text-muted-foreground line-clamp-2">{metadata.description}</p>
246+
)}
140247
<div className="flex items-center text-xs text-muted-foreground pt-1">
141248
<ExternalLink className="h-3 w-3 mr-1" />
142-
{metadata.domain}
249+
{metadata?.domain}
143250
</div>
144251
</div>
145252
</div>

0 commit comments

Comments
 (0)