+23
frontend/.gitignore
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}