A locally focused bluesky appview

make that UI betterer

+4
.gitignore
··· 1 + run.sh 2 + frontend/node_modules 3 + konbini 4 + sequence.txt
+23
frontend/.gitignore
··· 1 + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 + 3 + # dependencies 4 + /node_modules 5 + /.pnp 6 + .pnp.js 7 + 8 + # testing 9 + /coverage 10 + 11 + # production 12 + /build 13 + 14 + # misc 15 + .DS_Store 16 + .env.local 17 + .env.development.local 18 + .env.test.local 19 + .env.production.local 20 + 21 + npm-debug.log* 22 + yarn-debug.log* 23 + yarn-error.log*
+2
frontend/src/App.tsx
··· 3 3 import { FollowingFeed } from './components/FollowingFeed'; 4 4 import { ProfilePage } from './components/ProfilePage'; 5 5 import { PostView } from './components/PostView'; 6 + import { ThreadView } from './components/ThreadView'; 6 7 import './App.css'; 7 8 8 9 function Navigation() { ··· 37 38 <Route path="/" element={<FollowingFeed />} /> 38 39 <Route path="/profile/:account" element={<ProfilePage />} /> 39 40 <Route path="/profile/:account/post/:rkey" element={<PostView />} /> 41 + <Route path="/thread" element={<ThreadView />} /> 40 42 </Routes> 41 43 </main> 42 44 </div>
+38 -3
frontend/src/api.ts
··· 1 - import { PostResponse, ActorProfile, ApiError } from './types'; 1 + import { PostResponse, ActorProfile, ApiError, ThreadResponse, EngagementResponse, FeedResponse } from './types'; 2 2 3 3 const API_BASE_URL = 'http://localhost:4444/api'; 4 4 5 5 export class ApiClient { 6 - static async getFollowingFeed(): Promise<PostResponse[]> { 7 - const response = await fetch(`${API_BASE_URL}/followingfeed`); 6 + static async getFollowingFeed(cursor?: string): Promise<FeedResponse> { 7 + const url = cursor 8 + ? `${API_BASE_URL}/followingfeed?cursor=${encodeURIComponent(cursor)}` 9 + : `${API_BASE_URL}/followingfeed`; 10 + const response = await fetch(url); 8 11 if (!response.ok) { 9 12 throw new Error(`Failed to fetch following feed: ${response.statusText}`); 10 13 } ··· 31 34 const response = await fetch(`${API_BASE_URL}/profile/${encodeURIComponent(account)}/post/${encodeURIComponent(rkey)}`); 32 35 if (!response.ok) { 33 36 throw new Error(`Failed to fetch post: ${response.statusText}`); 37 + } 38 + return response.json(); 39 + } 40 + 41 + static async getThread(postId: number): Promise<ThreadResponse> { 42 + const response = await fetch(`${API_BASE_URL}/thread/${postId}`); 43 + if (!response.ok) { 44 + throw new Error(`Failed to fetch thread: ${response.statusText}`); 45 + } 46 + return response.json(); 47 + } 48 + 49 + static async getPostLikes(postId: number): Promise<EngagementResponse> { 50 + const response = await fetch(`${API_BASE_URL}/post/${postId}/likes`); 51 + if (!response.ok) { 52 + throw new Error(`Failed to fetch likes: ${response.statusText}`); 53 + } 54 + return response.json(); 55 + } 56 + 57 + static async getPostReposts(postId: number): Promise<EngagementResponse> { 58 + const response = await fetch(`${API_BASE_URL}/post/${postId}/reposts`); 59 + if (!response.ok) { 60 + throw new Error(`Failed to fetch reposts: ${response.statusText}`); 61 + } 62 + return response.json(); 63 + } 64 + 65 + static async getPostReplies(postId: number): Promise<EngagementResponse> { 66 + const response = await fetch(`${API_BASE_URL}/post/${postId}/replies`); 67 + if (!response.ok) { 68 + throw new Error(`Failed to fetch replies: ${response.statusText}`); 34 69 } 35 70 return response.json(); 36 71 }
+201
frontend/src/components/EngagementModal.css
··· 1 + .engagement-modal-backdrop { 2 + position: fixed; 3 + top: 0; 4 + left: 0; 5 + right: 0; 6 + bottom: 0; 7 + background-color: rgba(0, 0, 0, 0.5); 8 + display: flex; 9 + justify-content: center; 10 + align-items: center; 11 + z-index: 1000; 12 + padding: 20px; 13 + } 14 + 15 + .engagement-modal { 16 + background: white; 17 + border-radius: 16px; 18 + width: 100%; 19 + max-width: 600px; 20 + max-height: 80vh; 21 + display: flex; 22 + flex-direction: column; 23 + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15); 24 + animation: modalSlideIn 0.2s ease-out; 25 + } 26 + 27 + @keyframes modalSlideIn { 28 + from { 29 + opacity: 0; 30 + transform: translateY(-20px); 31 + } 32 + to { 33 + opacity: 1; 34 + transform: translateY(0); 35 + } 36 + } 37 + 38 + .engagement-modal-header { 39 + display: flex; 40 + justify-content: space-between; 41 + align-items: center; 42 + padding: 20px 24px; 43 + border-bottom: 1px solid #e1e8ed; 44 + } 45 + 46 + .engagement-modal-header h2 { 47 + margin: 0; 48 + font-size: 20px; 49 + font-weight: 700; 50 + color: #0f1419; 51 + } 52 + 53 + .modal-close-btn { 54 + background: none; 55 + border: none; 56 + font-size: 32px; 57 + color: #536471; 58 + cursor: pointer; 59 + padding: 0; 60 + width: 36px; 61 + height: 36px; 62 + display: flex; 63 + align-items: center; 64 + justify-content: center; 65 + border-radius: 50%; 66 + transition: background-color 0.2s; 67 + line-height: 1; 68 + } 69 + 70 + .modal-close-btn:hover { 71 + background-color: #f7f9fa; 72 + } 73 + 74 + .engagement-modal-content { 75 + flex: 1; 76 + overflow-y: auto; 77 + padding: 0; 78 + } 79 + 80 + .modal-loading, 81 + .modal-error, 82 + .modal-empty { 83 + text-align: center; 84 + padding: 40px 20px; 85 + color: #657786; 86 + } 87 + 88 + .modal-error { 89 + color: #e0245e; 90 + } 91 + 92 + .engagement-users-list { 93 + display: flex; 94 + flex-direction: column; 95 + } 96 + 97 + .engagement-user-item { 98 + display: flex; 99 + align-items: flex-start; 100 + gap: 12px; 101 + padding: 16px 24px; 102 + text-decoration: none; 103 + color: inherit; 104 + border-bottom: 1px solid #f0f0f0; 105 + transition: background-color 0.2s; 106 + } 107 + 108 + .engagement-user-item:hover { 109 + background-color: #f7f9fa; 110 + } 111 + 112 + .engagement-user-item:last-child { 113 + border-bottom: none; 114 + } 115 + 116 + .engagement-user-avatar { 117 + flex-shrink: 0; 118 + } 119 + 120 + .user-avatar-img { 121 + width: 48px; 122 + height: 48px; 123 + border-radius: 50%; 124 + object-fit: cover; 125 + } 126 + 127 + .user-avatar-placeholder { 128 + width: 48px; 129 + height: 48px; 130 + border-radius: 50%; 131 + background-color: #1da1f2; 132 + color: white; 133 + display: flex; 134 + align-items: center; 135 + justify-content: center; 136 + font-weight: 600; 137 + font-size: 20px; 138 + } 139 + 140 + .engagement-user-info { 141 + flex: 1; 142 + min-width: 0; 143 + } 144 + 145 + .user-display-name { 146 + font-weight: 700; 147 + font-size: 15px; 148 + color: #0f1419; 149 + line-height: 1.3; 150 + white-space: nowrap; 151 + overflow: hidden; 152 + text-overflow: ellipsis; 153 + } 154 + 155 + .user-handle { 156 + font-size: 14px; 157 + color: #536471; 158 + line-height: 1.3; 159 + white-space: nowrap; 160 + overflow: hidden; 161 + text-overflow: ellipsis; 162 + } 163 + 164 + .user-bio { 165 + font-size: 14px; 166 + color: #0f1419; 167 + line-height: 1.4; 168 + margin-top: 4px; 169 + display: -webkit-box; 170 + -webkit-line-clamp: 2; 171 + -webkit-box-orient: vertical; 172 + overflow: hidden; 173 + } 174 + 175 + .engagement-time { 176 + flex-shrink: 0; 177 + font-size: 13px; 178 + color: #657786; 179 + font-weight: 500; 180 + } 181 + 182 + @media (max-width: 768px) { 183 + .engagement-modal { 184 + max-height: 90vh; 185 + border-radius: 16px 16px 0 0; 186 + align-self: flex-end; 187 + } 188 + 189 + .engagement-modal-backdrop { 190 + align-items: flex-end; 191 + padding: 0; 192 + } 193 + 194 + .engagement-user-item { 195 + padding: 12px 16px; 196 + } 197 + 198 + .engagement-modal-header { 199 + padding: 16px 20px; 200 + } 201 + }
+125
frontend/src/components/EngagementModal.tsx
··· 1 + import React, { useState, useEffect } from 'react'; 2 + import { Link } from 'react-router-dom'; 3 + import { EngagementUser } from '../types'; 4 + import { ApiClient } from '../api'; 5 + import { getBlobUrl, getProfileUrl, formatRelativeTime } from '../utils'; 6 + import './EngagementModal.css'; 7 + 8 + interface EngagementModalProps { 9 + postId: number; 10 + type: 'likes' | 'reposts' | 'replies'; 11 + onClose: () => void; 12 + } 13 + 14 + export const EngagementModal: React.FC<EngagementModalProps> = ({ postId, type, onClose }) => { 15 + const [users, setUsers] = useState<EngagementUser[]>([]); 16 + const [loading, setLoading] = useState(true); 17 + const [error, setError] = useState<string | null>(null); 18 + 19 + useEffect(() => { 20 + const fetchEngagement = async () => { 21 + try { 22 + setLoading(true); 23 + setError(null); 24 + let data; 25 + 26 + switch (type) { 27 + case 'likes': 28 + data = await ApiClient.getPostLikes(postId); 29 + break; 30 + case 'reposts': 31 + data = await ApiClient.getPostReposts(postId); 32 + break; 33 + case 'replies': 34 + data = await ApiClient.getPostReplies(postId); 35 + break; 36 + } 37 + 38 + setUsers(data.users); 39 + } catch (err) { 40 + setError(err instanceof Error ? err.message : 'Failed to load data'); 41 + } finally { 42 + setLoading(false); 43 + } 44 + }; 45 + 46 + fetchEngagement(); 47 + }, [postId, type]); 48 + 49 + const getTitle = () => { 50 + switch (type) { 51 + case 'likes': 52 + return 'Liked by'; 53 + case 'reposts': 54 + return 'Reposted by'; 55 + case 'replies': 56 + return 'Replied by'; 57 + } 58 + }; 59 + 60 + const handleBackdropClick = (e: React.MouseEvent) => { 61 + if (e.target === e.currentTarget) { 62 + onClose(); 63 + } 64 + }; 65 + 66 + return ( 67 + <div className="engagement-modal-backdrop" onClick={handleBackdropClick}> 68 + <div className="engagement-modal"> 69 + <div className="engagement-modal-header"> 70 + <h2>{getTitle()}</h2> 71 + <button className="modal-close-btn" onClick={onClose}>×</button> 72 + </div> 73 + 74 + <div className="engagement-modal-content"> 75 + {loading && <div className="modal-loading">Loading...</div>} 76 + 77 + {error && <div className="modal-error">{error}</div>} 78 + 79 + {!loading && !error && users.length === 0 && ( 80 + <div className="modal-empty">No {type} yet</div> 81 + )} 82 + 83 + {!loading && !error && users.length > 0 && ( 84 + <div className="engagement-users-list"> 85 + {users.map((user, index) => ( 86 + <Link 87 + key={`${user.did}-${index}`} 88 + to={getProfileUrl(user.handle)} 89 + className="engagement-user-item" 90 + onClick={onClose} 91 + > 92 + <div className="engagement-user-avatar"> 93 + {user.profile?.avatar ? ( 94 + <img 95 + src={getBlobUrl(user.profile.avatar, user.did, 'avatar_thumbnail')} 96 + alt={`${user.handle}'s avatar`} 97 + className="user-avatar-img" 98 + /> 99 + ) : ( 100 + <div className="user-avatar-placeholder"> 101 + {user.handle.charAt(0).toUpperCase()} 102 + </div> 103 + )} 104 + </div> 105 + <div className="engagement-user-info"> 106 + <div className="user-display-name"> 107 + {user.profile?.displayName || user.handle} 108 + </div> 109 + <div className="user-handle">@{user.handle}</div> 110 + {user.profile?.description && ( 111 + <div className="user-bio">{user.profile.description}</div> 112 + )} 113 + </div> 114 + <div className="engagement-time"> 115 + {formatRelativeTime(user.time)} 116 + </div> 117 + </Link> 118 + ))} 119 + </div> 120 + )} 121 + </div> 122 + </div> 123 + </div> 124 + ); 125 + };
+35
frontend/src/components/FollowingFeed.css
··· 53 53 .empty-feed p { 54 54 margin: 0; 55 55 font-size: 16px; 56 + } 57 + 58 + .load-more-trigger { 59 + padding: 20px; 60 + text-align: center; 61 + min-height: 60px; 62 + } 63 + 64 + .loading-more { 65 + color: #536471; 66 + font-size: 14px; 67 + padding: 20px; 68 + text-align: center; 69 + animation: pulse 1.5s ease-in-out infinite; 70 + } 71 + 72 + @keyframes pulse { 73 + 0%, 100% { 74 + opacity: 1; 75 + } 76 + 50% { 77 + opacity: 0.5; 78 + } 79 + } 80 + 81 + .end-of-feed { 82 + text-align: center; 83 + padding: 40px 20px; 84 + color: #536471; 85 + font-style: italic; 86 + } 87 + 88 + .end-of-feed p { 89 + margin: 0; 90 + font-size: 14px; 56 91 }
+68 -15
frontend/src/components/FollowingFeed.tsx
··· 1 - import React, { useState, useEffect } from 'react'; 1 + import React, { useState, useEffect, useRef } from 'react'; 2 2 import { PostResponse } from '../types'; 3 3 import { ApiClient } from '../api'; 4 4 import { PostCard } from './PostCard'; ··· 7 7 export const FollowingFeed: React.FC = () => { 8 8 const [posts, setPosts] = useState<PostResponse[]>([]); 9 9 const [loading, setLoading] = useState(true); 10 + const [loadingMore, setLoadingMore] = useState(false); 10 11 const [error, setError] = useState<string | null>(null); 12 + const [cursor, setCursor] = useState<string | null>(null); 13 + const [hasMore, setHasMore] = useState(true); 14 + const observerTarget = useRef<HTMLDivElement>(null); 11 15 12 - useEffect(() => { 13 - const fetchFeed = async () => { 14 - try { 16 + const fetchFeed = async (cursorToUse?: string) => { 17 + try { 18 + if (cursorToUse) { 19 + setLoadingMore(true); 20 + } else { 15 21 setLoading(true); 16 - const feedData = await ApiClient.getFollowingFeed(); 17 - setPosts(feedData); 18 - } catch (err) { 19 - setError(err instanceof Error ? err.message : 'Failed to load feed'); 20 - } finally { 21 - setLoading(false); 22 22 } 23 - }; 24 23 24 + const feedData = await ApiClient.getFollowingFeed(cursorToUse || undefined); 25 + 26 + if (cursorToUse) { 27 + setPosts(prev => [...prev, ...feedData.posts]); 28 + } else { 29 + setPosts(feedData.posts); 30 + } 31 + 32 + setCursor(feedData.cursor || null); 33 + setHasMore(!!feedData.cursor && feedData.posts.length > 0); 34 + } catch (err) { 35 + setError(err instanceof Error ? err.message : 'Failed to load feed'); 36 + } finally { 37 + setLoading(false); 38 + setLoadingMore(false); 39 + } 40 + }; 41 + 42 + useEffect(() => { 25 43 fetchFeed(); 26 44 }, []); 27 45 46 + // Set up intersection observer for infinite scroll 47 + useEffect(() => { 48 + const observer = new IntersectionObserver( 49 + (entries) => { 50 + if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) { 51 + if (cursor) { 52 + fetchFeed(cursor); 53 + } 54 + } 55 + }, 56 + { threshold: 0.1 } 57 + ); 58 + 59 + const currentTarget = observerTarget.current; 60 + if (currentTarget) { 61 + observer.observe(currentTarget); 62 + } 63 + 64 + return () => { 65 + if (currentTarget) { 66 + observer.unobserve(currentTarget); 67 + } 68 + }; 69 + }, [hasMore, loadingMore, loading, cursor]); 70 + 28 71 if (loading) { 29 72 return ( 30 73 <div className="following-feed"> ··· 36 79 ); 37 80 } 38 81 39 - if (error) { 82 + if (error && posts.length === 0) { 40 83 return ( 41 84 <div className="following-feed"> 42 85 <div className="feed-header"> ··· 51 94 <div className="following-feed"> 52 95 <div className="feed-header"> 53 96 <h1>Following</h1> 54 - <p>{posts.length} recent posts</p> 97 + <p>{posts.length} posts loaded</p> 55 98 </div> 56 99 <div className="feed-content"> 57 100 {posts.map((post, index) => ( 58 101 <PostCard key={post.uri || index} postResponse={post} /> 59 102 ))} 60 - {posts.length === 0 && ( 103 + {posts.length === 0 && !loading && ( 61 104 <div className="empty-feed"> 62 105 <p>No posts in your following feed</p> 63 106 </div> 64 107 )} 108 + {hasMore && ( 109 + <div ref={observerTarget} className="load-more-trigger"> 110 + {loadingMore && <div className="loading-more">Loading more posts...</div>} 111 + </div> 112 + )} 113 + {!hasMore && posts.length > 0 && ( 114 + <div className="end-of-feed"> 115 + <p>You've reached the end!</p> 116 + </div> 117 + )} 65 118 </div> 66 119 </div> 67 120 ); 68 - }; 121 + };
+67
frontend/src/components/PostCard.css
··· 233 233 color: #1da1f2; 234 234 } 235 235 236 + .stat-item-clickable { 237 + background: none; 238 + border: none; 239 + padding: 4px 8px; 240 + margin: -4px; 241 + border-radius: 4px; 242 + cursor: pointer; 243 + transition: all 0.2s ease; 244 + } 245 + 246 + .stat-item-clickable:hover:not(:disabled) { 247 + background-color: rgba(29, 161, 242, 0.1); 248 + color: #1da1f2; 249 + } 250 + 251 + .stat-item-clickable:disabled { 252 + cursor: default; 253 + opacity: 0.6; 254 + } 255 + 256 + .stat-item-clickable:disabled:hover { 257 + background: none; 258 + color: #657786; 259 + } 260 + 236 261 .stat-icon { 237 262 font-size: 14px; 238 263 line-height: 1; ··· 245 270 246 271 .stat-count:empty::before { 247 272 content: "0"; 273 + } 274 + 275 + .view-thread-link { 276 + display: inline-block; 277 + margin-top: 8px; 278 + font-size: 13px; 279 + color: #1da1f2; 280 + text-decoration: none; 281 + font-weight: 500; 282 + transition: color 0.2s; 283 + } 284 + 285 + .view-thread-link:hover { 286 + color: #0c85d0; 287 + text-decoration: underline; 288 + } 289 + 290 + .post-reply-context { 291 + display: flex; 292 + align-items: center; 293 + justify-content: space-between; 294 + padding-top: 8px; 295 + margin-top: 8px; 296 + border-top: 1px solid #f0f0f0; 297 + font-size: 13px; 298 + } 299 + 300 + .reply-indicator { 301 + color: #657786; 302 + font-style: italic; 303 + } 304 + 305 + .view-full-thread { 306 + color: #1da1f2; 307 + text-decoration: none; 308 + font-weight: 500; 309 + transition: color 0.2s; 310 + } 311 + 312 + .view-full-thread:hover { 313 + color: #0c85d0; 314 + text-decoration: underline; 248 315 }
+45 -16
frontend/src/components/PostCard.tsx
··· 1 - import React from 'react'; 1 + import React, { useState } from 'react'; 2 2 import { FeedPost, PostResponse } from '../types'; 3 3 import { formatRelativeTime, getBlobUrl, getPostUrl, getProfileUrl, parseAtUri } from '../utils'; 4 4 import { Link } from 'react-router-dom'; 5 + import { EngagementModal } from './EngagementModal'; 5 6 import './PostCard.css'; 6 7 7 8 interface PostCardProps { 8 9 postResponse: PostResponse; 10 + showThreadIndicator?: boolean; 9 11 } 10 12 11 - export const PostCard: React.FC<PostCardProps> = ({ postResponse }) => { 13 + export const PostCard: React.FC<PostCardProps> = ({ postResponse, showThreadIndicator = true }) => { 14 + const [showEngagementModal, setShowEngagementModal] = useState<'likes' | 'reposts' | 'replies' | null>(null); 15 + 12 16 if (postResponse.missing || !postResponse.post) { 13 17 return ( 14 18 <div className="post-card post-card--missing"> ··· 72 76 <div className="post-card"> 73 77 {postResponse.author && ( 74 78 <div className="post-author"> 75 - <Link to={getProfileUrl(postResponse.author.did)} className="author-link"> 79 + <Link to={getProfileUrl(postResponse.author.handle)} className="author-link"> 76 80 <div className="author-avatar"> 77 81 {postResponse.author.profile?.avatar ? ( 78 82 <img ··· 102 106 <p className="post-text">{post.text}</p> 103 107 {renderEmbed(post)} 104 108 </div> 105 - <div className="post-meta"> 106 - {post.langs && post.langs.length > 0 && ( 107 - <span className="post-langs"> 108 - {post.langs.join(', ')} 109 - </span> 110 - )} 111 - </div> 112 109 </Link> 113 110 114 111 {postResponse.counts && ( 115 112 <div className="post-engagement"> 116 113 <div className="engagement-stats"> 117 - <span className="stat-item"> 114 + <button 115 + className="stat-item stat-item-clickable" 116 + onClick={(e) => { 117 + e.preventDefault(); 118 + e.stopPropagation(); 119 + setShowEngagementModal('likes'); 120 + }} 121 + disabled={postResponse.counts.likes === 0} 122 + > 118 123 <span className="stat-icon">♥</span> 119 124 <span className="stat-count">{postResponse.counts.likes}</span> 120 - </span> 121 - <span className="stat-item"> 125 + </button> 126 + <button 127 + className="stat-item stat-item-clickable" 128 + onClick={(e) => { 129 + e.preventDefault(); 130 + e.stopPropagation(); 131 + setShowEngagementModal('reposts'); 132 + }} 133 + disabled={postResponse.counts.reposts === 0} 134 + > 122 135 <span className="stat-icon">🔄</span> 123 136 <span className="stat-count">{postResponse.counts.reposts}</span> 124 - </span> 125 - <span className="stat-item"> 137 + </button> 138 + <button 139 + className="stat-item stat-item-clickable" 140 + onClick={(e) => { 141 + e.preventDefault(); 142 + e.stopPropagation(); 143 + setShowEngagementModal('replies'); 144 + }} 145 + disabled={postResponse.counts.replies === 0} 146 + > 126 147 <span className="stat-icon">💬</span> 127 148 <span className="stat-count">{postResponse.counts.replies}</span> 128 - </span> 149 + </button> 129 150 </div> 130 151 </div> 152 + )} 153 + 154 + {showEngagementModal && ( 155 + <EngagementModal 156 + postId={postResponse.id} 157 + type={showEngagementModal} 158 + onClose={() => setShowEngagementModal(null)} 159 + /> 131 160 )} 132 161 </div> 133 162 );
+42 -156
frontend/src/components/PostView.css
··· 5 5 min-height: 100vh; 6 6 } 7 7 8 - .post-header { 8 + .post-view-header { 9 9 display: flex; 10 10 align-items: center; 11 11 padding: 16px 20px; ··· 28 28 text-decoration: underline; 29 29 } 30 30 31 - .post-header h1 { 31 + .post-view-header h1 { 32 32 margin: 0; 33 33 font-size: 20px; 34 34 font-weight: 700; 35 35 color: #0f1419; 36 36 } 37 37 38 - .post-content { 39 - padding: 20px; 38 + .post-view-content { 39 + padding: 0; 40 40 } 41 41 42 - .post-author { 43 - margin-bottom: 16px; 42 + .main-post { 43 + border-bottom: 2px solid #1da1f2; 44 + background: #f7fafc; 44 45 } 45 46 46 - .author-link { 47 - color: #1da1f2; 48 - text-decoration: none; 49 - font-weight: 600; 50 - font-size: 16px; 51 - } 52 - 53 - .author-link:hover { 54 - text-decoration: underline; 55 - } 56 - 57 - .post-main { 58 - margin-bottom: 20px; 47 + .main-post .post-card { 48 + margin-bottom: 0; 49 + border: none; 50 + border-radius: 0; 59 51 } 60 52 61 - .post-text { 62 - margin: 0 0 16px 0; 63 - font-size: 18px; 64 - line-height: 1.5; 65 - color: #0f1419; 66 - white-space: pre-wrap; 53 + .thread-replies { 54 + padding: 0; 67 55 } 68 56 69 - .post-embed { 70 - margin-top: 16px; 57 + .replies-header { 58 + padding: 16px 20px; 59 + border-bottom: 1px solid #e1e8ed; 60 + background: white; 71 61 } 72 62 73 - .post-embed--images { 74 - display: grid; 75 - gap: 12px; 76 - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 77 - } 78 - 79 - .image-container { 80 - border-radius: 8px; 81 - overflow: hidden; 82 - border: 1px solid #e1e8ed; 83 - } 84 - 85 - .post-image { 86 - width: 100%; 87 - height: auto; 88 - max-height: 500px; 89 - object-fit: cover; 90 - display: block; 91 - } 92 - 93 - .image-alt { 94 - padding: 8px 12px; 63 + .replies-header h2 { 95 64 margin: 0; 96 - font-size: 13px; 97 - color: #536471; 98 - background-color: #f7f9fa; 99 - border-top: 1px solid #e1e8ed; 100 - } 101 - 102 - .post-embed--external { 103 - border: 1px solid #e1e8ed; 104 - border-radius: 12px; 105 - overflow: hidden; 106 - } 107 - 108 - .external-link { 109 - display: block; 110 - text-decoration: none; 111 - color: inherit; 112 - } 113 - 114 - .external-link:hover { 115 - background-color: #f7f9fa; 116 - } 117 - 118 - .external-thumb { 119 - width: 100%; 120 - height: 200px; 121 - object-fit: cover; 122 - } 123 - 124 - .external-content { 125 - padding: 16px; 126 - } 127 - 128 - .external-content h3 { 129 - margin: 0 0 8px 0; 130 65 font-size: 16px; 131 - font-weight: 600; 66 + font-weight: 700; 132 67 color: #0f1419; 133 68 } 134 69 135 - .external-content p { 136 - margin: 0 0 8px 0; 137 - font-size: 14px; 138 - color: #536471; 139 - line-height: 1.4; 140 - } 141 - 142 - .external-content small { 143 - font-size: 13px; 144 - color: #657786; 145 - word-break: break-all; 146 - } 147 - 148 - .post-embed--record { 149 - border: 1px solid #e1e8ed; 150 - border-radius: 12px; 151 - padding: 16px; 152 - background-color: #f7f9fa; 70 + .thread-reply { 71 + position: relative; 72 + border-left: 2px solid #e1e8ed; 73 + margin-left: 40px; 153 74 } 154 75 155 - .quoted-post p { 156 - margin: 0 0 8px 0; 157 - font-size: 14px; 158 - color: #536471; 76 + .thread-reply::before { 77 + content: ''; 78 + position: absolute; 79 + left: -2px; 80 + top: 20px; 81 + width: 30px; 82 + height: 2px; 83 + background-color: #e1e8ed; 159 84 } 160 85 161 - .quoted-link { 162 - color: #1da1f2; 163 - text-decoration: none; 164 - font-size: 13px; 165 - word-break: break-all; 166 - } 167 - 168 - .quoted-link:hover { 169 - text-decoration: underline; 170 - } 171 - 172 - .post-meta { 173 - padding: 16px 0; 174 - border-top: 1px solid #e1e8ed; 175 - border-bottom: 1px solid #e1e8ed; 176 - } 177 - 178 - .post-time { 179 - display: block; 180 - font-size: 15px; 181 - color: #536471; 182 - margin-bottom: 8px; 183 - } 184 - 185 - .post-langs { 186 - font-size: 13px; 187 - color: #657786; 188 - } 189 - 190 - .post-uri { 191 - margin-top: 16px; 192 - padding: 12px; 193 - background-color: #f7f9fa; 194 - border-radius: 8px; 195 - } 196 - 197 - .post-uri small { 198 - font-size: 12px; 199 - color: #657786; 200 - word-break: break-all; 201 - font-family: monospace; 86 + .thread-reply .post-card { 87 + margin-bottom: 0; 88 + border-left: none; 89 + border-right: none; 90 + border-top: none; 91 + border-radius: 0; 202 92 } 203 93 204 94 .loading { ··· 224 114 margin: 0; 225 115 } 226 116 227 - .post-content { 228 - padding: 16px; 229 - } 230 - 231 - .post-text { 232 - font-size: 16px; 117 + .thread-reply { 118 + margin-left: 20px; 233 119 } 234 120 235 - .post-embed--images { 236 - grid-template-columns: 1fr; 121 + .thread-reply::before { 122 + width: 15px; 237 123 } 238 - } 124 + }
+57 -88
frontend/src/components/PostView.tsx
··· 1 1 import React, { useState, useEffect } from 'react'; 2 2 import { useParams, Link } from 'react-router-dom'; 3 - import { FeedPost } from '../types'; 3 + import { PostResponse } from '../types'; 4 4 import { ApiClient } from '../api'; 5 - import { formatDate, getBlobUrl, parseAtUri, getProfileUrl } from '../utils'; 5 + import { PostCard } from './PostCard'; 6 6 import './PostView.css'; 7 7 8 8 export const PostView: React.FC = () => { 9 9 const { account, rkey } = useParams<{ account: string; rkey: string }>(); 10 - const [post, setPost] = useState<FeedPost | null>(null); 10 + const [mainPost, setMainPost] = useState<PostResponse | null>(null); 11 + const [threadPosts, setThreadPosts] = useState<PostResponse[]>([]); 11 12 const [loading, setLoading] = useState(true); 12 13 const [error, setError] = useState<string | null>(null); 13 14 14 15 useEffect(() => { 15 - const fetchPost = async () => { 16 + // Scroll to top when navigating to a post 17 + window.scrollTo(0, 0); 18 + 19 + const fetchPostAndThread = async () => { 16 20 if (!account || !rkey) return; 17 21 18 22 try { 19 23 setLoading(true); 20 24 setError(null); 21 25 22 - const postData = await ApiClient.getPost(account, rkey); 23 - setPost(postData); 26 + // First, get all posts from the profile to find this specific post 27 + const profilePosts = await ApiClient.getProfilePosts(account); 28 + const targetPost = profilePosts.find(p => { 29 + const uriParts = p.uri.split('/'); 30 + return uriParts[uriParts.length - 1] === rkey; 31 + }); 32 + 33 + if (!targetPost) { 34 + setError('Post not found'); 35 + setLoading(false); 36 + return; 37 + } 38 + 39 + setMainPost(targetPost); 40 + 41 + // If this post has replies or is part of a thread, fetch the thread 42 + if (targetPost.counts && targetPost.counts.replies > 0) { 43 + try { 44 + const threadData = await ApiClient.getThread(targetPost.id); 45 + // Filter out the main post and only show replies 46 + const replies = threadData.posts.filter(p => p.id !== targetPost.id); 47 + setThreadPosts(replies); 48 + } catch (err) { 49 + console.error('Failed to load thread:', err); 50 + // Don't fail if thread loading fails 51 + } 52 + } 24 53 } catch (err) { 25 54 setError(err instanceof Error ? err.message : 'Failed to load post'); 26 55 } finally { ··· 28 57 } 29 58 }; 30 59 31 - fetchPost(); 60 + fetchPostAndThread(); 32 61 }, [account, rkey]); 33 62 34 - const renderEmbed = (post: FeedPost) => { 35 - if (!post.embed) return null; 36 - 37 - switch (post.embed.$type) { 38 - case 'app.bsky.embed.images': 39 - return ( 40 - <div className="post-embed post-embed--images"> 41 - {post.embed.images.map((img, idx) => ( 42 - <div key={idx} className="image-container"> 43 - <img 44 - src={getBlobUrl(img.image, account, 'feed_thumbnail')} 45 - alt={img.alt} 46 - className="post-image" 47 - /> 48 - {img.alt && <p className="image-alt">{img.alt}</p>} 49 - </div> 50 - ))} 51 - </div> 52 - ); 53 - 54 - case 'app.bsky.embed.external': 55 - return ( 56 - <div className="post-embed post-embed--external"> 57 - <a href={post.embed.external.uri} target="_blank" rel="noopener noreferrer" className="external-link"> 58 - {post.embed.external.thumb && ( 59 - <img src={getBlobUrl(post.embed.external.thumb, account, 'feed_thumbnail')} alt="" className="external-thumb" /> 60 - )} 61 - <div className="external-content"> 62 - <h3>{post.embed.external.title}</h3> 63 - <p>{post.embed.external.description}</p> 64 - <small>{post.embed.external.uri}</small> 65 - </div> 66 - </a> 67 - </div> 68 - ); 69 - 70 - case 'app.bsky.embed.record': 71 - const quoted = parseAtUri(post.embed.record.uri); 72 - return ( 73 - <div className="post-embed post-embed--record"> 74 - <div className="quoted-post"> 75 - <p>Quoted post:</p> 76 - <Link to={`/profile/${quoted?.did}/post/${quoted?.rkey}`} className="quoted-link"> 77 - {post.embed.record.uri} 78 - </Link> 79 - </div> 80 - </div> 81 - ); 82 - 83 - default: 84 - return null; 85 - } 86 - }; 87 - 88 63 if (loading) { 89 64 return ( 90 65 <div className="post-view"> 66 + <div className="post-view-header"> 67 + <Link to="/" className="back-link">← Back</Link> 68 + </div> 91 69 <div className="loading">Loading post...</div> 92 70 </div> 93 71 ); 94 72 } 95 73 96 - if (error || !post) { 74 + if (error || !mainPost) { 97 75 return ( 98 76 <div className="post-view"> 99 - <div className="post-header"> 77 + <div className="post-view-header"> 100 78 <Link to="/" className="back-link">← Back</Link> 101 79 </div> 102 80 <div className="error"> ··· 108 86 109 87 return ( 110 88 <div className="post-view"> 111 - <div className="post-header"> 89 + <div className="post-view-header"> 112 90 <Link to="/" className="back-link">← Back</Link> 113 91 <h1>Post</h1> 114 92 </div> 115 93 116 - <div className="post-content"> 117 - <div className="post-author"> 118 - <Link to={getProfileUrl(account!)} className="author-link"> 119 - @{account} 120 - </Link> 121 - </div> 122 - 123 - <div className="post-main"> 124 - <p className="post-text">{post.text}</p> 125 - {renderEmbed(post)} 94 + <div className="post-view-content"> 95 + <div className="main-post"> 96 + <PostCard postResponse={mainPost} showThreadIndicator={false} /> 126 97 </div> 127 98 128 - <div className="post-meta"> 129 - <time className="post-time" dateTime={post.createdAt}> 130 - {formatDate(post.createdAt)} 131 - </time> 132 - {post.langs && post.langs.length > 0 && ( 133 - <div className="post-langs"> 134 - Languages: {post.langs.join(', ')} 99 + {threadPosts.length > 0 && ( 100 + <div className="thread-replies"> 101 + <div className="replies-header"> 102 + <h2>Replies</h2> 135 103 </div> 136 - )} 137 - </div> 138 - 139 - <div className="post-uri"> 140 - <small>at://{account}/app.bsky.feed.post/{rkey}</small> 141 - </div> 104 + {threadPosts.map((post, index) => ( 105 + <div key={post.uri || index} className="thread-reply"> 106 + <PostCard postResponse={post} showThreadIndicator={false} /> 107 + </div> 108 + ))} 109 + </div> 110 + )} 142 111 </div> 143 112 </div> 144 113 ); 145 - }; 114 + };
+21
frontend/src/components/ProfilePage.css
··· 9 9 position: relative; 10 10 background: white; 11 11 border-bottom: 1px solid #e1e8ed; 12 + margin-bottom: 16px; 12 13 } 13 14 14 15 .profile-banner { ··· 16 17 height: 200px; 17 18 background: linear-gradient(135deg, #1da1f2, #14171a); 18 19 overflow: hidden; 20 + flex-shrink: 0; 19 21 } 20 22 21 23 .profile-banner img { 22 24 width: 100%; 23 25 height: 100%; 24 26 object-fit: cover; 27 + display: block; 25 28 } 26 29 27 30 .profile-info { 28 31 position: relative; 29 32 padding: 16px 20px; 33 + min-height: 80px; 30 34 } 31 35 32 36 .profile-avatar-section { 33 37 position: absolute; 34 38 top: -60px; 35 39 left: 20px; 40 + z-index: 10; 41 + } 42 + 43 + /* When there's no banner, adjust the layout */ 44 + .profile-header--no-banner .profile-avatar-section { 45 + position: relative; 46 + top: 0; 47 + margin-bottom: 16px; 48 + } 49 + 50 + .profile-header--no-banner .profile-details { 51 + margin-top: 0; 52 + } 53 + 54 + .profile-header--no-banner { 55 + padding-top: 20px; 36 56 } 37 57 38 58 .profile-avatar { ··· 42 62 border-radius: 50%; 43 63 overflow: hidden; 44 64 background: white; 65 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 45 66 } 46 67 47 68 .profile-avatar img {
+16 -5
frontend/src/components/ProfilePage.tsx
··· 12 12 const [posts, setPosts] = useState<PostResponse[]>([]); 13 13 const [loading, setLoading] = useState(true); 14 14 const [error, setError] = useState<string | null>(null); 15 + const [userDid, setUserDid] = useState<string | null>(null); 15 16 16 17 useEffect(() => { 18 + // Scroll to top when navigating to a profile 19 + window.scrollTo(0, 0); 20 + 17 21 const fetchProfile = async () => { 18 22 if (!account) return; 19 23 ··· 38 42 } 39 43 40 44 setPosts(Array.isArray(postsData) ? postsData : []); 45 + 46 + // Extract DID from posts if available (posts include author info with DID) 47 + if (Array.isArray(postsData) && postsData.length > 0 && postsData[0].author) { 48 + setUserDid(postsData[0].author.did); 49 + } 41 50 } catch (err) { 42 51 setError(err instanceof Error ? err.message : 'Failed to load data'); 43 52 } finally { ··· 65 74 </div> 66 75 ); 67 76 } 77 + 78 + const hasBanner = !!(profile?.banner && userDid); 68 79 69 80 return ( 70 81 <div className="profile-page"> 71 - <div className="profile-header"> 72 - {profile && profile.banner && ( 82 + <div className={`profile-header ${!hasBanner ? 'profile-header--no-banner' : ''}`}> 83 + {hasBanner && profile.banner && ( 73 84 <div className="profile-banner"> 74 - <img src={getBlobUrl(profile.banner, account, 'feed_thumbnail')} alt="Profile banner" /> 85 + <img src={getBlobUrl(profile.banner, userDid!, 'feed_thumbnail')} alt="Profile banner" /> 75 86 </div> 76 87 )} 77 88 78 89 <div className="profile-info"> 79 90 <div className="profile-avatar-section"> 80 - {profile && profile.avatar ? ( 91 + {profile?.avatar && userDid ? ( 81 92 <div className="profile-avatar"> 82 - <img src={getBlobUrl(profile.avatar, account, 'avatar_thumbnail')} alt="Profile avatar" /> 93 + <img src={getBlobUrl(profile.avatar, userDid!, 'avatar_thumbnail')} alt="Profile avatar" /> 83 94 </div> 84 95 ) : ( 85 96 <div className="profile-avatar profile-avatar--placeholder">
+108
frontend/src/components/ThreadView.css
··· 1 + .thread-view { 2 + max-width: 800px; 3 + margin: 0 auto; 4 + padding: 20px; 5 + } 6 + 7 + .thread-header { 8 + margin-bottom: 30px; 9 + border-bottom: 1px solid #e1e8ed; 10 + padding-bottom: 15px; 11 + } 12 + 13 + .thread-header h1 { 14 + margin: 10px 0; 15 + font-size: 24px; 16 + font-weight: 700; 17 + } 18 + 19 + .thread-info { 20 + color: #657786; 21 + font-size: 14px; 22 + margin: 5px 0 0 0; 23 + } 24 + 25 + .back-link { 26 + display: inline-block; 27 + color: #1da1f2; 28 + text-decoration: none; 29 + font-size: 14px; 30 + margin-bottom: 10px; 31 + transition: color 0.2s; 32 + } 33 + 34 + .back-link:hover { 35 + color: #0c85d0; 36 + text-decoration: underline; 37 + } 38 + 39 + .thread-content { 40 + display: flex; 41 + flex-direction: column; 42 + gap: 0; 43 + } 44 + 45 + .thread-root { 46 + margin-bottom: 10px; 47 + } 48 + 49 + .thread-root .post-card { 50 + border: 2px solid #1da1f2; 51 + border-radius: 12px; 52 + } 53 + 54 + .thread-replies { 55 + display: flex; 56 + flex-direction: column; 57 + } 58 + 59 + .thread-reply { 60 + position: relative; 61 + margin-left: 30px; 62 + border-left: 2px solid #e1e8ed; 63 + padding-left: 20px; 64 + } 65 + 66 + .thread-reply::before { 67 + content: ''; 68 + position: absolute; 69 + left: -2px; 70 + top: 20px; 71 + width: 20px; 72 + height: 2px; 73 + background-color: #e1e8ed; 74 + } 75 + 76 + .thread-reply .post-card { 77 + margin-bottom: 10px; 78 + } 79 + 80 + .thread-node { 81 + margin-bottom: 10px; 82 + } 83 + 84 + .loading, 85 + .error { 86 + text-align: center; 87 + padding: 40px 20px; 88 + color: #657786; 89 + } 90 + 91 + .error { 92 + color: #e0245e; 93 + } 94 + 95 + @media (max-width: 768px) { 96 + .thread-view { 97 + padding: 10px; 98 + } 99 + 100 + .thread-reply { 101 + margin-left: 15px; 102 + padding-left: 10px; 103 + } 104 + 105 + .thread-reply::before { 106 + width: 10px; 107 + } 108 + }
+169
frontend/src/components/ThreadView.tsx
··· 1 + import React, { useState, useEffect } from 'react'; 2 + import { useParams, useSearchParams, Link } from 'react-router-dom'; 3 + import { ThreadResponse, PostResponse } from '../types'; 4 + import { ApiClient } from '../api'; 5 + import { PostCard } from './PostCard'; 6 + import './ThreadView.css'; 7 + 8 + interface ThreadNode { 9 + post: PostResponse; 10 + replies: ThreadNode[]; 11 + } 12 + 13 + export const ThreadView: React.FC = () => { 14 + const [searchParams] = useSearchParams(); 15 + const postIdParam = searchParams.get('postId'); 16 + const [threadData, setThreadData] = useState<ThreadResponse | null>(null); 17 + const [loading, setLoading] = useState(true); 18 + const [error, setError] = useState<string | null>(null); 19 + 20 + useEffect(() => { 21 + // Scroll to top when navigating to a thread 22 + window.scrollTo(0, 0); 23 + 24 + const fetchThread = async () => { 25 + if (!postIdParam) { 26 + setError('No post ID provided'); 27 + setLoading(false); 28 + return; 29 + } 30 + 31 + try { 32 + setLoading(true); 33 + setError(null); 34 + const data = await ApiClient.getThread(parseInt(postIdParam)); 35 + setThreadData(data); 36 + } catch (err) { 37 + setError(err instanceof Error ? err.message : 'Failed to load thread'); 38 + } finally { 39 + setLoading(false); 40 + } 41 + }; 42 + 43 + fetchThread(); 44 + }, [postIdParam]); 45 + 46 + // Build a tree structure from flat posts array 47 + const buildThreadTree = (posts: PostResponse[]): ThreadNode[] => { 48 + const postMap = new Map<number, ThreadNode>(); 49 + const roots: ThreadNode[] = []; 50 + 51 + // Create nodes for all posts 52 + posts.forEach(post => { 53 + const postId = extractPostId(post.uri); 54 + if (postId) { 55 + postMap.set(postId, { post, replies: [] }); 56 + } 57 + }); 58 + 59 + // Build the tree structure 60 + posts.forEach(post => { 61 + const postId = extractPostId(post.uri); 62 + if (!postId) return; 63 + 64 + const node = postMap.get(postId); 65 + if (!node) return; 66 + 67 + if (post.replyTo && post.replyTo !== 0) { 68 + const parentNode = postMap.get(post.replyTo); 69 + if (parentNode) { 70 + parentNode.replies.push(node); 71 + } else { 72 + // Parent not in thread, treat as root 73 + roots.push(node); 74 + } 75 + } else { 76 + // No parent, this is a root 77 + roots.push(node); 78 + } 79 + }); 80 + 81 + return roots; 82 + }; 83 + 84 + const extractPostId = (uri: string): number | null => { 85 + // Extract post ID from URI - we'll need to look it up somehow 86 + // For now, we'll rely on the posts being in order and having the inThread field 87 + const post = threadData?.posts.find(p => p.uri === uri); 88 + if (!post) return null; 89 + 90 + // We need a way to get the post ID - let's use a different approach 91 + // We'll match by checking if this is the root or using array index as fallback 92 + const index = threadData?.posts.indexOf(post); 93 + return index !== undefined ? index : null; 94 + }; 95 + 96 + const renderThreadNode = (node: ThreadNode, depth: number = 0): React.ReactNode => { 97 + return ( 98 + <div key={node.post.uri} className="thread-node" style={{ marginLeft: `${depth * 20}px` }}> 99 + <PostCard postResponse={node.post} showThreadIndicator={false} /> 100 + {node.replies.length > 0 && ( 101 + <div className="thread-replies"> 102 + {node.replies.map(reply => renderThreadNode(reply, depth + 1))} 103 + </div> 104 + )} 105 + </div> 106 + ); 107 + }; 108 + 109 + if (loading) { 110 + return ( 111 + <div className="thread-view"> 112 + <div className="thread-header"> 113 + <Link to="/" className="back-link">← Back</Link> 114 + <h1>Thread</h1> 115 + </div> 116 + <div className="loading">Loading thread...</div> 117 + </div> 118 + ); 119 + } 120 + 121 + if (error || !threadData) { 122 + return ( 123 + <div className="thread-view"> 124 + <div className="thread-header"> 125 + <Link to="/" className="back-link">← Back</Link> 126 + <h1>Thread</h1> 127 + </div> 128 + <div className="error">{error || 'Failed to load thread'}</div> 129 + </div> 130 + ); 131 + } 132 + 133 + // For now, let's just render posts in order since building the tree is complex without post IDs 134 + // We'll show them with indentation based on replyTo relationships 135 + const renderSimpleThread = () => { 136 + const rootPost = threadData.posts.find(p => p.inThread === 0 || !p.inThread); 137 + const replyPosts = threadData.posts.filter(p => p.inThread !== 0 && p.inThread); 138 + 139 + return ( 140 + <div className="thread-content"> 141 + {rootPost && ( 142 + <div className="thread-root"> 143 + <PostCard postResponse={rootPost} showThreadIndicator={false} /> 144 + </div> 145 + )} 146 + {replyPosts.length > 0 && ( 147 + <div className="thread-replies"> 148 + {replyPosts.map((post, index) => ( 149 + <div key={post.uri || index} className="thread-reply"> 150 + <PostCard postResponse={post} showThreadIndicator={false} /> 151 + </div> 152 + ))} 153 + </div> 154 + )} 155 + </div> 156 + ); 157 + }; 158 + 159 + return ( 160 + <div className="thread-view"> 161 + <div className="thread-header"> 162 + <Link to="/" className="back-link">← Back</Link> 163 + <h1>Thread</h1> 164 + <p className="thread-info">{threadData.posts.length} posts in conversation</p> 165 + </div> 166 + {renderSimpleThread()} 167 + </div> 168 + ); 169 + };
+26
frontend/src/types.ts
··· 84 84 post?: FeedPost; 85 85 author?: AuthorInfo; 86 86 counts?: PostCounts; 87 + id: number; 88 + replyTo?: number; 89 + replyToUsr?: number; 90 + inThread?: number; 91 + } 92 + 93 + export interface ThreadResponse { 94 + posts: PostResponse[]; 95 + rootPostId: number; 87 96 } 88 97 89 98 export interface ActorProfile { ··· 101 110 102 111 export interface ApiError { 103 112 error: string; 113 + } 114 + 115 + export interface EngagementUser { 116 + handle: string; 117 + did: string; 118 + profile?: ActorProfile; 119 + time: string; 120 + } 121 + 122 + export interface EngagementResponse { 123 + users: EngagementUser[]; 124 + count: number; 125 + } 126 + 127 + export interface FeedResponse { 128 + posts: PostResponse[]; 129 + cursor: string; 104 130 }
+8
frontend/src/utils.ts
··· 23 23 24 24 export function getBlobUrl(blob: BlobRef, did?: string, type: 'avatar_thumbnail' | 'feed_thumbnail' = 'feed_thumbnail'): string { 25 25 // Use Bluesky CDN format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@jpeg 26 + 27 + // Handle cases where blob or blob.ref is undefined/malformed 28 + if (!blob || !blob.ref || !blob.ref.$link) { 29 + console.warn('Invalid blob reference:', blob); 30 + // Return a placeholder or empty data URL 31 + return 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100"%3E%3Crect fill="%23ddd" width="100" height="100"/%3E%3C/svg%3E'; 32 + } 33 + 26 34 const cid = blob.ref.$link; 27 35 const didParam = did || 'unknown'; 28 36 return `https://cdn.bsky.app/img/${type}/plain/${didParam}/${cid}@jpeg`;
+373 -33
handlers.go
··· 5 5 "context" 6 6 "fmt" 7 7 "log/slog" 8 + "sync" 9 + "time" 8 10 9 11 "github.com/bluesky-social/indigo/api/bsky" 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 11 13 "github.com/labstack/echo/v4" 12 14 "github.com/labstack/echo/v4/middleware" 15 + "github.com/labstack/gommon/log" 13 16 "github.com/whyrusleeping/market/models" 14 17 ) 15 18 ··· 23 26 views.GET("/profile/:account", s.handleGetProfileView) 24 27 views.GET("/profile/:account/posts", s.handleGetProfilePosts) 25 28 views.GET("/followingfeed", s.handleGetFollowingFeed) 29 + views.GET("/thread/:postid", s.handleGetThread) 30 + views.GET("/post/:postid/likes", s.handleGetPostLikes) 31 + views.GET("/post/:postid/reposts", s.handleGetPostReposts) 32 + views.GET("/post/:postid/replies", s.handleGetPostReplies) 26 33 27 34 return e.Start(":4444") 28 35 } ··· 90 97 } 91 98 92 99 if profile.Raw == nil || len(profile.Raw) == 0 { 100 + s.addMissingProfile(ctx, accdid) 93 101 return e.JSON(404, map[string]any{ 94 102 "error": "missing profile info for user", 95 103 }) ··· 153 161 Post: &fp, 154 162 AuthorInfo: author, 155 163 Counts: counts, 164 + ID: p.ID, 165 + ReplyTo: p.ReplyTo, 166 + ReplyToUsr: p.ReplyToUsr, 167 + InThread: p.InThread, 156 168 }) 157 169 } 158 170 ··· 171 183 Post *bsky.FeedPost `json:"post"` 172 184 AuthorInfo *authorInfo `json:"author"` 173 185 Counts *postCounts `json:"counts"` 186 + ID uint `json:"id"` 187 + ReplyTo uint `json:"replyTo,omitempty"` 188 + ReplyToUsr uint `json:"replyToUsr,omitempty"` 189 + InThread uint `json:"inThread,omitempty"` 174 190 } 175 191 176 192 type authorInfo struct { ··· 187 203 return err 188 204 } 189 205 206 + // Get cursor from query parameter (timestamp in RFC3339 format) 207 + cursor := e.QueryParam("cursor") 208 + limit := 20 209 + 210 + tcursor := time.Now() 211 + if cursor != "" { 212 + t, err := time.Parse(time.RFC3339, cursor) 213 + if err != nil { 214 + return fmt.Errorf("invalid cursor: %w", err) 215 + } 216 + tcursor = t 217 + } 190 218 var dbposts []models.Post 191 - if err := s.backend.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) order by created DESC limit 10 ", myr.ID).Scan(&dbposts).Error; err != nil { 219 + if err := s.backend.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) AND created < ? order by created DESC limit ?", myr.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 220 + return err 221 + } 222 + 223 + posts := make([]postResponse, len(dbposts)) 224 + var wg sync.WaitGroup 225 + 226 + for i := range dbposts { 227 + wg.Add(1) 228 + go func(ix int) { 229 + defer wg.Done() 230 + p := dbposts[ix] 231 + r, err := s.backend.getRepoByID(ctx, p.Author) 232 + if err != nil { 233 + fmt.Println("failed to get repo: ", err) 234 + posts[ix] = postResponse{ 235 + Uri: "", 236 + Missing: true, 237 + } 238 + return 239 + } 240 + 241 + uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey) 242 + if len(p.Raw) == 0 || p.NotFound { 243 + posts[ix] = postResponse{ 244 + Uri: uri, 245 + Missing: true, 246 + } 247 + return 248 + } 249 + 250 + var fp bsky.FeedPost 251 + if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err != nil { 252 + log.Warn("failed to unmarshal post", "uri", uri, "error", err) 253 + posts[ix] = postResponse{ 254 + Uri: uri, 255 + Missing: true, 256 + } 257 + return 258 + } 259 + 260 + author, err := s.getAuthorInfo(ctx, r) 261 + if err != nil { 262 + slog.Error("failed to load author info for post", "error", err) 263 + } 264 + 265 + counts, err := s.getPostCounts(ctx, p.ID) 266 + if err != nil { 267 + slog.Error("failed to get counts for post", "post", p.ID, "error", err) 268 + } 269 + 270 + posts[ix] = postResponse{ 271 + Uri: uri, 272 + Post: &fp, 273 + AuthorInfo: author, 274 + Counts: counts, 275 + ID: p.ID, 276 + ReplyTo: p.ReplyTo, 277 + ReplyToUsr: p.ReplyToUsr, 278 + InThread: p.InThread, 279 + } 280 + }(i) 281 + } 282 + 283 + wg.Wait() 284 + 285 + // Generate next cursor from the last post's timestamp 286 + var nextCursor string 287 + if len(dbposts) > 0 { 288 + nextCursor = dbposts[len(dbposts)-1].Created.Format(time.RFC3339) 289 + } 290 + 291 + return e.JSON(200, map[string]any{ 292 + "posts": posts, 293 + "cursor": nextCursor, 294 + }) 295 + } 296 + 297 + func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) { 298 + var profile models.Profile 299 + if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 300 + return nil, err 301 + } 302 + 303 + resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 304 + if err != nil { 305 + return nil, err 306 + } 307 + 308 + if profile.Raw == nil || len(profile.Raw) == 0 { 309 + s.addMissingProfile(ctx, r.Did) 310 + return &authorInfo{ 311 + Handle: resp.Handle.String(), 312 + Did: r.Did, 313 + }, nil 314 + } 315 + 316 + var prof bsky.ActorProfile 317 + if err := prof.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err != nil { 318 + return nil, err 319 + } 320 + 321 + return &authorInfo{ 322 + Handle: resp.Handle.String(), 323 + Did: r.Did, 324 + Profile: &prof, 325 + }, nil 326 + } 327 + 328 + func (s *Server) getPostCounts(ctx context.Context, pid uint) (*postCounts, error) { 329 + var pc postCounts 330 + if err := s.backend.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil { 331 + return nil, err 332 + } 333 + if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil { 334 + return nil, err 335 + } 336 + if err := s.backend.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil { 337 + return nil, err 338 + } 339 + 340 + return &pc, nil 341 + } 342 + 343 + func (s *Server) handleGetThread(e echo.Context) error { 344 + ctx := e.Request().Context() 345 + 346 + postIDStr := e.Param("postid") 347 + var postID uint 348 + if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 349 + return e.JSON(400, map[string]any{ 350 + "error": "invalid post ID", 351 + }) 352 + } 353 + 354 + // Get the requested post to find the thread root 355 + var requestedPost models.Post 356 + if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil { 192 357 return err 193 358 } 194 359 360 + if requestedPost.ID == 0 { 361 + return e.JSON(404, map[string]any{ 362 + "error": "post not found", 363 + }) 364 + } 365 + 366 + // Determine the root post ID 367 + rootPostID := postID 368 + if requestedPost.InThread != 0 { 369 + rootPostID = requestedPost.InThread 370 + } 371 + 372 + // Get all posts in this thread 373 + var dbposts []models.Post 374 + query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC" 375 + if err := s.backend.db.Raw(query, rootPostID, rootPostID).Scan(&dbposts).Error; err != nil { 376 + return err 377 + } 378 + 379 + // Build response for each post 195 380 posts := []postResponse{} 196 381 for _, p := range dbposts { 197 382 r, err := s.backend.getRepoByID(ctx, p.Author) ··· 202 387 uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey) 203 388 if len(p.Raw) == 0 || p.NotFound { 204 389 posts = append(posts, postResponse{ 205 - Uri: uri, 206 - Missing: true, 390 + Uri: uri, 391 + Missing: true, 392 + ReplyTo: p.ReplyTo, 393 + ReplyToUsr: p.ReplyToUsr, 394 + InThread: p.InThread, 207 395 }) 208 396 continue 209 397 } 398 + 210 399 var fp bsky.FeedPost 211 400 if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err != nil { 212 401 return err ··· 227 416 Post: &fp, 228 417 AuthorInfo: author, 229 418 Counts: counts, 419 + ID: p.ID, 420 + ReplyTo: p.ReplyTo, 421 + ReplyToUsr: p.ReplyToUsr, 422 + InThread: p.InThread, 230 423 }) 231 424 } 232 425 233 - return e.JSON(200, posts) 426 + return e.JSON(200, map[string]any{ 427 + "posts": posts, 428 + "rootPostId": rootPostID, 429 + }) 234 430 } 235 431 236 - func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) { 237 - var profile models.Profile 238 - if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 239 - return nil, err 432 + type engagementUser struct { 433 + Handle string `json:"handle"` 434 + Did string `json:"did"` 435 + Profile *bsky.ActorProfile `json:"profile,omitempty"` 436 + Time string `json:"time"` 437 + } 438 + 439 + func (s *Server) handleGetPostLikes(e echo.Context) error { 440 + ctx := e.Request().Context() 441 + 442 + postIDStr := e.Param("postid") 443 + var postID uint 444 + if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 445 + return e.JSON(400, map[string]any{ 446 + "error": "invalid post ID", 447 + }) 240 448 } 241 449 242 - resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 243 - if err != nil { 244 - return nil, err 450 + // Get all likes for this post 451 + var likes []models.Like 452 + if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil { 453 + return err 245 454 } 246 455 247 - if profile.Raw == nil || len(profile.Raw) == 0 { 248 - return &authorInfo{ 249 - Handle: resp.Handle.String(), 250 - Did: r.Did, 251 - }, nil 456 + users := []engagementUser{} 457 + for _, like := range likes { 458 + r, err := s.backend.getRepoByID(ctx, like.Author) 459 + if err != nil { 460 + slog.Error("failed to get repo for like author", "error", err) 461 + continue 462 + } 463 + 464 + // Look up handle 465 + resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 466 + if err != nil { 467 + slog.Error("failed to lookup DID", "did", r.Did, "error", err) 468 + continue 469 + } 470 + 471 + // Get profile if available 472 + var profile models.Profile 473 + s.backend.db.Find(&profile, "repo = ?", r.ID) 474 + 475 + var prof *bsky.ActorProfile 476 + if len(profile.Raw) > 0 { 477 + var p bsky.ActorProfile 478 + if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil { 479 + prof = &p 480 + } 481 + } 482 + 483 + users = append(users, engagementUser{ 484 + Handle: resp.Handle.String(), 485 + Did: r.Did, 486 + Profile: prof, 487 + Time: like.Created.Format("2006-01-02T15:04:05Z"), 488 + }) 252 489 } 253 490 254 - var prof bsky.ActorProfile 255 - if err := prof.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err != nil { 256 - return nil, err 491 + return e.JSON(200, map[string]any{ 492 + "users": users, 493 + "count": len(users), 494 + }) 495 + } 496 + 497 + func (s *Server) handleGetPostReposts(e echo.Context) error { 498 + ctx := e.Request().Context() 499 + 500 + postIDStr := e.Param("postid") 501 + var postID uint 502 + if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 503 + return e.JSON(400, map[string]any{ 504 + "error": "invalid post ID", 505 + }) 506 + } 507 + 508 + // Get all reposts for this post 509 + var reposts []models.Repost 510 + if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil { 511 + return err 512 + } 513 + 514 + users := []engagementUser{} 515 + for _, repost := range reposts { 516 + r, err := s.backend.getRepoByID(ctx, repost.Author) 517 + if err != nil { 518 + slog.Error("failed to get repo for repost author", "error", err) 519 + continue 520 + } 521 + 522 + // Look up handle 523 + resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 524 + if err != nil { 525 + slog.Error("failed to lookup DID", "did", r.Did, "error", err) 526 + continue 527 + } 528 + 529 + // Get profile if available 530 + var profile models.Profile 531 + s.backend.db.Find(&profile, "repo = ?", r.ID) 532 + 533 + var prof *bsky.ActorProfile 534 + if len(profile.Raw) > 0 { 535 + var p bsky.ActorProfile 536 + if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil { 537 + prof = &p 538 + } 539 + } 540 + 541 + users = append(users, engagementUser{ 542 + Handle: resp.Handle.String(), 543 + Did: r.Did, 544 + Profile: prof, 545 + Time: repost.Created.Format("2006-01-02T15:04:05Z"), 546 + }) 257 547 } 258 548 259 - return &authorInfo{ 260 - Handle: resp.Handle.String(), 261 - Did: r.Did, 262 - Profile: &prof, 263 - }, nil 549 + return e.JSON(200, map[string]any{ 550 + "users": users, 551 + "count": len(users), 552 + }) 264 553 } 265 554 266 - func (s *Server) getPostCounts(ctx context.Context, pid uint) (*postCounts, error) { 267 - var pc postCounts 268 - if err := s.backend.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil { 269 - return nil, err 555 + func (s *Server) handleGetPostReplies(e echo.Context) error { 556 + ctx := e.Request().Context() 557 + 558 + postIDStr := e.Param("postid") 559 + var postID uint 560 + if _, err := fmt.Sscanf(postIDStr, "%d", &postID); err != nil { 561 + return e.JSON(400, map[string]any{ 562 + "error": "invalid post ID", 563 + }) 270 564 } 271 - if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil { 272 - return nil, err 565 + 566 + // Get all replies to this post 567 + var replies []models.Post 568 + if err := s.backend.db.Find(&replies, "reply_to = ?", postID).Error; err != nil { 569 + return err 273 570 } 274 - if err := s.backend.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil { 275 - return nil, err 571 + 572 + users := []engagementUser{} 573 + seen := make(map[uint]bool) // Track unique authors 574 + 575 + for _, reply := range replies { 576 + // Skip if we've already added this author 577 + if seen[reply.Author] { 578 + continue 579 + } 580 + seen[reply.Author] = true 581 + 582 + r, err := s.backend.getRepoByID(ctx, reply.Author) 583 + if err != nil { 584 + slog.Error("failed to get repo for reply author", "error", err) 585 + continue 586 + } 587 + 588 + // Look up handle 589 + resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did)) 590 + if err != nil { 591 + slog.Error("failed to lookup DID", "did", r.Did, "error", err) 592 + continue 593 + } 594 + 595 + // Get profile if available 596 + var profile models.Profile 597 + s.backend.db.Find(&profile, "repo = ?", r.ID) 598 + 599 + var prof *bsky.ActorProfile 600 + if len(profile.Raw) > 0 { 601 + var p bsky.ActorProfile 602 + if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil { 603 + prof = &p 604 + } 605 + } 606 + 607 + users = append(users, engagementUser{ 608 + Handle: resp.Handle.String(), 609 + Did: r.Did, 610 + Profile: prof, 611 + Time: reply.Created.Format("2006-01-02T15:04:05Z"), 612 + }) 276 613 } 277 614 278 - return &pc, nil 615 + return e.JSON(200, map[string]any{ 616 + "users": users, 617 + "count": len(users), 618 + }) 279 619 }
+17 -20
main.go
··· 8 8 "log" 9 9 "log/slog" 10 10 "net/http" 11 + _ "net/http/pprof" 11 12 "net/url" 12 13 "os" 13 14 "runtime" ··· 46 47 Help: "A histogram of op handling durations", 47 48 Buckets: prometheus.ExponentialBuckets(1, 2, 15), 48 49 }, []string{"op", "collection"}) 49 - 50 - var doEmbedHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ 51 - Name: "do_embed_hist", 52 - Help: "A histogram of embedding computation time", 53 - Buckets: prometheus.ExponentialBucketsRange(0.001, 30, 20), 54 - }, []string{"model"}) 55 - 56 - var embeddingTimeHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ 57 - Name: "embed_timing", 58 - Help: "A histogram of embedding computation time", 59 - Buckets: prometheus.ExponentialBucketsRange(0.001, 30, 20), 60 - }, []string{"model", "phase", "host"}) 61 - 62 - var refreshEmbeddingHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ 63 - Name: "refresh_embed_timing", 64 - Help: "A histogram of embedding refresh times", 65 - Buckets: prometheus.ExponentialBucketsRange(0.001, 30, 20), 66 - }, []string{"host"}) 67 50 68 51 var firehoseCursorGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ 69 52 Name: "firehose_cursor", ··· 100 83 Colorful: true, 101 84 }) 102 85 103 - //db.AutoMigrate(cursorRecord{}) 104 - //db.AutoMigrate(MarketConfig{}) 105 86 db.AutoMigrate(Repo{}) 106 87 db.AutoMigrate(Post{}) 107 88 db.AutoMigrate(Follow{}) ··· 177 158 mydid: mydid, 178 159 client: cc, 179 160 dir: dir, 161 + 162 + missingProfiles: make(chan string, 1024), 180 163 } 181 164 182 165 pgb := &PostgresBackend{ ··· 199 182 fmt.Println("failed to start api server: ", err) 200 183 } 201 184 }() 185 + 186 + go func() { 187 + http.ListenAndServe(":4445", nil) 188 + }() 189 + 190 + go s.missingProfileFetcher() 202 191 203 192 seqno, err := loadLastSeq("sequence.txt") 204 193 if err != nil { ··· 221 210 222 211 seqLk sync.Mutex 223 212 lastSeq int64 213 + 214 + mpLk sync.Mutex 215 + missingProfiles chan string 216 + } 217 + 218 + func (s *Server) getXrpcClient() (*xrpc.Client, error) { 219 + // TODO: handle refreshing the token periodically 220 + return s.client, nil 224 221 } 225 222 226 223 func (s *Server) startLiveTail(ctx context.Context, curs int, parWorkers, maxQ int) error {
+67
missing.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/api/bsky" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + "github.com/ipfs/go-cid" 13 + "github.com/labstack/gommon/log" 14 + ) 15 + 16 + func (s *Server) addMissingProfile(ctx context.Context, did string) { 17 + select { 18 + case s.missingProfiles <- did: 19 + case <-ctx.Done(): 20 + } 21 + } 22 + 23 + func (s *Server) missingProfileFetcher() { 24 + for did := range s.missingProfiles { 25 + if err := s.fetchMissingProfile(context.TODO(), did); err != nil { 26 + log.Warn("failed to fetch missing profile", "did", did, "error", err) 27 + } 28 + } 29 + } 30 + 31 + func (s *Server) fetchMissingProfile(ctx context.Context, did string) error { 32 + repo, err := s.backend.getOrCreateRepo(ctx, did) 33 + if err != nil { 34 + return err 35 + } 36 + 37 + resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 38 + if err != nil { 39 + return err 40 + } 41 + 42 + c := &xrpc.Client{ 43 + Host: resp.PDSEndpoint(), 44 + } 45 + 46 + rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self") 47 + if err != nil { 48 + return err 49 + } 50 + 51 + prof, ok := rec.Value.Val.(*bsky.ActorProfile) 52 + if !ok { 53 + return fmt.Errorf("record we got back wasnt a profile somehow") 54 + } 55 + 56 + buf := new(bytes.Buffer) 57 + if err := prof.MarshalCBOR(buf); err != nil { 58 + return err 59 + } 60 + 61 + cc, err := cid.Decode(*rec.Cid) 62 + if err != nil { 63 + return err 64 + } 65 + 66 + return s.backend.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc) 67 + }