+3
.env.example
+3
.env.example
+32
Dockerfile
+32
Dockerfile
···
1
+
# Build stage
2
+
FROM golang:1.21-alpine AS builder
3
+
4
+
WORKDIR /app
5
+
6
+
# Install build dependencies
7
+
RUN apk add --no-cache git
8
+
9
+
# Copy go mod files
10
+
COPY go.mod go.sum ./
11
+
RUN go mod download
12
+
13
+
# Copy source code
14
+
COPY *.go ./
15
+
16
+
# Build the application
17
+
RUN CGO_ENABLED=0 GOOS=linux go build -o konbini .
18
+
19
+
# Runtime stage
20
+
FROM alpine:latest
21
+
22
+
RUN apk --no-cache add ca-certificates
23
+
24
+
WORKDIR /root/
25
+
26
+
# Copy the binary from builder
27
+
COPY --from=builder /app/konbini .
28
+
29
+
# Expose the API port
30
+
EXPOSE 4444
31
+
32
+
CMD ["./konbini"]
+43
-1
README.md
+43
-1
README.md
···
12
12
- Docker (optional, for easy PostgreSQL setup)
13
13
- Bluesky account credentials
14
14
15
-
## Setup
15
+
## Quick Start with Docker Compose
16
+
17
+
The easiest way to run Konbini is with Docker Compose, which will start PostgreSQL, the backend, and frontend all together.
18
+
19
+
### Prerequisites
20
+
21
+
- Docker and Docker Compose installed
22
+
- Your Bluesky DID (find it at https://bsky.app/settings/account)
23
+
24
+
### Setup
25
+
26
+
1. Create a `.env` file with your credentials:
27
+
28
+
```bash
29
+
cp .env.example .env
30
+
# Edit .env and add:
31
+
# - BSKY_HANDLE=your-handle.bsky.social
32
+
# - BSKY_PASSWORD=your-app-password
33
+
```
34
+
35
+
2. Start all services:
36
+
37
+
```bash
38
+
docker-compose up -d
39
+
```
40
+
41
+
3. Wait for the backend to index posts from the firehose (this may take a few minutes for initial indexing)
42
+
43
+
4. Open your browser to http://localhost:3000
44
+
45
+
### Stopping the services
46
+
47
+
```bash
48
+
docker-compose down
49
+
```
50
+
51
+
To also remove the database volume:
52
+
53
+
```bash
54
+
docker-compose down -v
55
+
```
56
+
57
+
## Manual Setup
16
58
17
59
### 1. PostgreSQL Database Setup
18
60
+49
docker-compose.yml
+49
docker-compose.yml
···
1
+
version: '3.8'
2
+
3
+
services:
4
+
postgres:
5
+
image: postgres:15-alpine
6
+
container_name: konbini-db
7
+
environment:
8
+
POSTGRES_DB: konbini
9
+
POSTGRES_USER: konbini
10
+
POSTGRES_PASSWORD: konbini_password
11
+
ports:
12
+
- "5432:5432"
13
+
volumes:
14
+
- postgres_data:/var/lib/postgresql/data
15
+
healthcheck:
16
+
test: ["CMD-SHELL", "pg_isready -U konbini"]
17
+
interval: 10s
18
+
timeout: 5s
19
+
retries: 5
20
+
21
+
backend:
22
+
build:
23
+
context: .
24
+
dockerfile: Dockerfile
25
+
container_name: konbini-backend
26
+
environment:
27
+
- DATABASE_URL=postgres://konbini:konbini_password@postgres:5432/konbini?sslmode=disable
28
+
- BSKY_HANDLE=${BSKY_HANDLE}
29
+
- BSKY_PASSWORD=${BSKY_PASSWORD}
30
+
ports:
31
+
- "4444:4444"
32
+
depends_on:
33
+
postgres:
34
+
condition: service_healthy
35
+
restart: unless-stopped
36
+
37
+
frontend:
38
+
build:
39
+
context: ./frontend
40
+
dockerfile: Dockerfile
41
+
container_name: konbini-frontend
42
+
ports:
43
+
- "3000:80"
44
+
depends_on:
45
+
- backend
46
+
restart: unless-stopped
47
+
48
+
volumes:
49
+
postgres_data:
+29
frontend/Dockerfile
+29
frontend/Dockerfile
···
1
+
# Build stage
2
+
FROM node:18-alpine AS builder
3
+
4
+
WORKDIR /app
5
+
6
+
# Copy package files
7
+
COPY package*.json ./
8
+
9
+
# Install dependencies
10
+
RUN npm ci
11
+
12
+
# Copy source code
13
+
COPY . .
14
+
15
+
# Build the app
16
+
RUN npm run build
17
+
18
+
# Runtime stage - serve with nginx
19
+
FROM nginx:alpine
20
+
21
+
# Copy built assets from builder (react-scripts builds to 'build' directory)
22
+
COPY --from=builder /app/build /usr/share/nginx/html
23
+
24
+
# Copy nginx configuration
25
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
26
+
27
+
EXPOSE 80
28
+
29
+
CMD ["nginx", "-g", "daemon off;"]
+23
frontend/nginx.conf
+23
frontend/nginx.conf
···
1
+
server {
2
+
listen 80;
3
+
server_name localhost;
4
+
root /usr/share/nginx/html;
5
+
index index.html;
6
+
7
+
# Enable gzip compression
8
+
gzip on;
9
+
gzip_vary on;
10
+
gzip_min_length 1024;
11
+
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
12
+
13
+
# Serve static files
14
+
location / {
15
+
try_files $uri $uri/ /index.html;
16
+
}
17
+
18
+
# Cache static assets
19
+
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
20
+
expires 1y;
21
+
add_header Cache-Control "public, immutable";
22
+
}
23
+
}
+15
-1
frontend/src/App.tsx
+15
-1
frontend/src/App.tsx
···
1
-
import React from 'react';
1
+
import React, { useState } from 'react';
2
2
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
3
3
import { FollowingFeed } from './components/FollowingFeed';
4
4
import { ProfilePage } from './components/ProfilePage';
5
5
import { PostView } from './components/PostView';
6
6
import { ThreadView } from './components/ThreadView';
7
+
import { PostComposer } from './components/PostComposer';
7
8
import './App.css';
8
9
9
10
function Navigation() {
···
29
30
}
30
31
31
32
function App() {
33
+
const [showComposer, setShowComposer] = useState(false);
34
+
32
35
return (
33
36
<Router>
34
37
<div className="app">
···
41
44
<Route path="/thread" element={<ThreadView />} />
42
45
</Routes>
43
46
</main>
47
+
<button className="floating-post-button" onClick={() => setShowComposer(true)}>
48
+
POST
49
+
</button>
50
+
{showComposer && (
51
+
<PostComposer
52
+
onClose={() => setShowComposer(false)}
53
+
onPostCreated={() => {
54
+
window.location.reload();
55
+
}}
56
+
/>
57
+
)}
44
58
</div>
45
59
</Router>
46
60
);
+41
-2
frontend/src/api.ts
+41
-2
frontend/src/api.ts
···
22
22
return response.json();
23
23
}
24
24
25
-
static async getProfilePosts(account: string): Promise<PostResponse[]> {
26
-
const response = await fetch(`${API_BASE_URL}/profile/${encodeURIComponent(account)}/posts`);
25
+
static async getProfilePosts(account: string, cursor?: string): Promise<FeedResponse> {
26
+
const url = cursor
27
+
? `${API_BASE_URL}/profile/${encodeURIComponent(account)}/posts?cursor=${encodeURIComponent(cursor)}`
28
+
: `${API_BASE_URL}/profile/${encodeURIComponent(account)}/posts`;
29
+
const response = await fetch(url);
27
30
if (!response.ok) {
28
31
throw new Error(`Failed to fetch profile posts: ${response.statusText}`);
29
32
}
···
68
71
throw new Error(`Failed to fetch replies: ${response.statusText}`);
69
72
}
70
73
return response.json();
74
+
}
75
+
76
+
static async createRecord(collection: string, record: Record<string, any>): Promise<{uri: string, cid: string}> {
77
+
const response = await fetch(`${API_BASE_URL}/createRecord`, {
78
+
method: 'POST',
79
+
headers: {
80
+
'Content-Type': 'application/json',
81
+
},
82
+
body: JSON.stringify({
83
+
collection,
84
+
record,
85
+
}),
86
+
});
87
+
if (!response.ok) {
88
+
throw new Error(`Failed to create record: ${response.statusText}`);
89
+
}
90
+
return response.json();
91
+
}
92
+
93
+
static async likePost(postUri: string, postCid: string): Promise<{uri: string, cid: string}> {
94
+
return this.createRecord('app.bsky.feed.like', {
95
+
$type: 'app.bsky.feed.like',
96
+
subject: {
97
+
uri: postUri,
98
+
cid: postCid,
99
+
},
100
+
createdAt: new Date().toISOString(),
101
+
});
102
+
}
103
+
104
+
static async createPost(text: string): Promise<{uri: string, cid: string}> {
105
+
return this.createRecord('app.bsky.feed.post', {
106
+
$type: 'app.bsky.feed.post',
107
+
text: text,
108
+
createdAt: new Date().toISOString(),
109
+
});
71
110
}
72
111
}
+14
-20
frontend/src/components/FollowingFeed.tsx
+14
-20
frontend/src/components/FollowingFeed.tsx
···
1
-
import React, { useState, useEffect, useRef } from 'react';
2
-
import { PostResponse } from '../types';
3
-
import { ApiClient } from '../api';
4
-
import { PostCard } from './PostCard';
5
-
import './FollowingFeed.css';
1
+
import React, { useState, useEffect, useRef } from "react";
2
+
import { PostResponse } from "../types";
3
+
import { ApiClient } from "../api";
4
+
import { PostCard } from "./PostCard";
5
+
import "./FollowingFeed.css";
6
6
7
7
export const FollowingFeed: React.FC = () => {
8
8
const [posts, setPosts] = useState<PostResponse[]>([]);
···
21
21
setLoading(true);
22
22
}
23
23
24
-
const feedData = await ApiClient.getFollowingFeed(cursorToUse || undefined);
24
+
const feedData = await ApiClient.getFollowingFeed(
25
+
cursorToUse || undefined,
26
+
);
25
27
26
28
if (cursorToUse) {
27
-
setPosts(prev => [...prev, ...feedData.posts]);
29
+
setPosts((prev) => [...prev, ...feedData.posts]);
28
30
} else {
29
31
setPosts(feedData.posts);
30
32
}
···
32
34
setCursor(feedData.cursor || null);
33
35
setHasMore(!!feedData.cursor && feedData.posts.length > 0);
34
36
} catch (err) {
35
-
setError(err instanceof Error ? err.message : 'Failed to load feed');
37
+
setError(err instanceof Error ? err.message : "Failed to load feed");
36
38
} finally {
37
39
setLoading(false);
38
40
setLoadingMore(false);
···
53
55
}
54
56
}
55
57
},
56
-
{ threshold: 0.1 }
58
+
{ threshold: 0.1 },
57
59
);
58
60
59
61
const currentTarget = observerTarget.current;
···
71
73
if (loading) {
72
74
return (
73
75
<div className="following-feed">
74
-
<div className="feed-header">
75
-
<h1>Following</h1>
76
-
</div>
77
76
<div className="loading">Loading your feed...</div>
78
77
</div>
79
78
);
···
82
81
if (error && posts.length === 0) {
83
82
return (
84
83
<div className="following-feed">
85
-
<div className="feed-header">
86
-
<h1>Following</h1>
87
-
</div>
88
84
<div className="error">Error: {error}</div>
89
85
</div>
90
86
);
···
92
88
93
89
return (
94
90
<div className="following-feed">
95
-
<div className="feed-header">
96
-
<h1>Following</h1>
97
-
<p>{posts.length} posts loaded</p>
98
-
</div>
99
91
<div className="feed-content">
100
92
{posts.map((post, index) => (
101
93
<PostCard key={post.uri || index} postResponse={post} />
···
107
99
)}
108
100
{hasMore && (
109
101
<div ref={observerTarget} className="load-more-trigger">
110
-
{loadingMore && <div className="loading-more">Loading more posts...</div>}
102
+
{loadingMore && (
103
+
<div className="loading-more">Loading more posts...</div>
104
+
)}
111
105
</div>
112
106
)}
113
107
{!hasMore && posts.length > 0 && (
+124
-4
frontend/src/components/PostCard.css
+124
-4
frontend/src/components/PostCard.css
···
87
87
text-overflow: ellipsis;
88
88
}
89
89
90
-
.post-content-link {
90
+
.post-text-link {
91
91
display: block;
92
92
text-decoration: none;
93
93
color: inherit;
94
94
}
95
95
96
-
.post-content-link:hover {
96
+
.post-text-link:hover {
97
97
text-decoration: none;
98
98
color: inherit;
99
99
}
···
178
178
}
179
179
180
180
.post-embed--record {
181
+
border: 1px solid #e1e8ed;
182
+
border-radius: 12px;
183
+
overflow: hidden;
184
+
background-color: white;
185
+
}
186
+
187
+
.quoted-post {
181
188
padding: 12px;
182
189
background-color: #f7f9fa;
183
-
border: 1px solid #e1e8ed;
184
-
border-radius: 8px;
190
+
transition: background-color 0.2s;
191
+
}
192
+
193
+
.quoted-post:hover {
194
+
background-color: #eef3f7;
195
+
}
196
+
197
+
.quoted-post-header {
198
+
margin-bottom: 8px;
199
+
}
200
+
201
+
.quoted-author-link {
202
+
display: flex;
203
+
align-items: center;
204
+
text-decoration: none;
205
+
color: inherit;
206
+
}
207
+
208
+
.quoted-author-link:hover {
209
+
text-decoration: none;
210
+
}
211
+
212
+
.quoted-author-avatar {
213
+
margin-right: 8px;
214
+
flex-shrink: 0;
215
+
}
216
+
217
+
.avatar-image-small {
218
+
width: 20px;
219
+
height: 20px;
220
+
border-radius: 50%;
221
+
object-fit: cover;
222
+
}
223
+
224
+
.avatar-placeholder-small {
225
+
width: 20px;
226
+
height: 20px;
227
+
border-radius: 50%;
228
+
background-color: #1da1f2;
229
+
color: white;
230
+
display: flex;
231
+
align-items: center;
232
+
justify-content: center;
233
+
font-weight: 600;
234
+
font-size: 10px;
235
+
}
236
+
237
+
.quoted-author-info {
238
+
display: flex;
239
+
align-items: center;
240
+
gap: 4px;
241
+
min-width: 0;
242
+
flex: 1;
243
+
}
244
+
245
+
.quoted-author-name {
246
+
font-weight: 600;
247
+
color: #0f1419;
185
248
font-size: 13px;
249
+
white-space: nowrap;
250
+
overflow: hidden;
251
+
text-overflow: ellipsis;
252
+
}
253
+
254
+
.quoted-author-handle {
186
255
color: #536471;
256
+
font-size: 13px;
257
+
white-space: nowrap;
258
+
overflow: hidden;
259
+
text-overflow: ellipsis;
260
+
}
261
+
262
+
.quoted-post-content {
263
+
margin-top: 4px;
264
+
}
265
+
266
+
.quoted-post-text {
267
+
margin: 0;
268
+
font-size: 14px;
269
+
color: #0f1419;
270
+
line-height: 1.4;
271
+
white-space: pre-wrap;
272
+
}
273
+
274
+
.quoted-post-missing {
275
+
padding: 12px;
276
+
display: flex;
277
+
align-items: center;
278
+
gap: 8px;
279
+
color: #657786;
280
+
font-size: 14px;
281
+
font-style: italic;
282
+
}
283
+
284
+
.missing-icon {
285
+
font-size: 16px;
286
+
opacity: 0.7;
287
+
}
288
+
289
+
.missing-text {
290
+
opacity: 0.8;
187
291
}
188
292
189
293
.post-meta {
···
261
365
.stat-icon {
262
366
font-size: 14px;
263
367
line-height: 1;
368
+
}
369
+
370
+
.stat-icon-unliked {
371
+
color: #657786;
372
+
}
373
+
374
+
.stat-icon-liked {
375
+
color: #e0245e;
376
+
}
377
+
378
+
.stat-item-liked:hover:not(:disabled) {
379
+
background-color: rgba(224, 36, 94, 0.1);
380
+
}
381
+
382
+
.stat-item-liked:hover:not(:disabled) .stat-icon-liked {
383
+
color: #c91a4f;
264
384
}
265
385
266
386
.stat-count {
+87
-15
frontend/src/components/PostCard.tsx
+87
-15
frontend/src/components/PostCard.tsx
···
3
3
import { formatRelativeTime, getBlobUrl, getPostUrl, getProfileUrl, parseAtUri } from '../utils';
4
4
import { Link } from 'react-router-dom';
5
5
import { EngagementModal } from './EngagementModal';
6
+
import { ApiClient } from '../api';
6
7
import './PostCard.css';
7
8
8
9
interface PostCardProps {
···
12
13
13
14
export const PostCard: React.FC<PostCardProps> = ({ postResponse, showThreadIndicator = true }) => {
14
15
const [showEngagementModal, setShowEngagementModal] = useState<'likes' | 'reposts' | 'replies' | null>(null);
16
+
const [isLiking, setIsLiking] = useState(false);
17
+
const [localLikeCount, setLocalLikeCount] = useState(postResponse.counts?.likes || 0);
18
+
const [isLiked, setIsLiked] = useState(!!postResponse.viewerLike);
15
19
16
20
if (postResponse.missing || !postResponse.post) {
17
21
return (
···
26
30
const postUri = parseAtUri(postResponse.uri);
27
31
const authorDid = postUri?.did;
28
32
33
+
const handleLike = async (e: React.MouseEvent) => {
34
+
e.preventDefault();
35
+
e.stopPropagation();
36
+
37
+
if (isLiking) return;
38
+
39
+
console.log('Liking post:', { uri: postResponse.uri, cid: postResponse.cid });
40
+
41
+
if (!postResponse.cid) {
42
+
console.error('Post CID is missing!');
43
+
alert('Cannot like post: missing CID');
44
+
return;
45
+
}
46
+
47
+
setIsLiking(true);
48
+
try {
49
+
await ApiClient.likePost(postResponse.uri, postResponse.cid);
50
+
setLocalLikeCount(prev => prev + 1);
51
+
setIsLiked(true);
52
+
} catch (err) {
53
+
console.error('Failed to like post:', err);
54
+
alert('Failed to like post. Please try again.');
55
+
} finally {
56
+
setIsLiking(false);
57
+
}
58
+
};
59
+
29
60
const renderEmbed = (post: FeedPost) => {
30
61
if (!post.embed) return null;
31
62
···
61
92
);
62
93
63
94
case 'app.bsky.embed.record':
95
+
const quotedRecord = post.embed.record;
96
+
// Check if we have the full record view with author and value
97
+
if ('author' in quotedRecord && 'value' in quotedRecord && quotedRecord.author && quotedRecord.value) {
98
+
const quotedAuthor = quotedRecord.author;
99
+
const quotedPost = quotedRecord.value;
100
+
101
+
return (
102
+
<div className="post-embed post-embed--record">
103
+
<div className="quoted-post">
104
+
<div className="quoted-post-header">
105
+
<Link to={getProfileUrl(quotedAuthor.handle)} className="quoted-author-link">
106
+
<div className="quoted-author-avatar">
107
+
{quotedAuthor.profile?.avatar ? (
108
+
<img
109
+
src={getBlobUrl(quotedAuthor.profile.avatar, quotedAuthor.did, 'avatar_thumbnail')}
110
+
alt="Author avatar"
111
+
className="avatar-image-small"
112
+
/>
113
+
) : (
114
+
<div className="avatar-placeholder-small">
115
+
{quotedAuthor.handle.charAt(0).toUpperCase()}
116
+
</div>
117
+
)}
118
+
</div>
119
+
<div className="quoted-author-info">
120
+
<span className="quoted-author-name">
121
+
{quotedAuthor.profile?.displayName || quotedAuthor.handle}
122
+
</span>
123
+
<span className="quoted-author-handle">@{quotedAuthor.handle}</span>
124
+
</div>
125
+
</Link>
126
+
</div>
127
+
<div className="quoted-post-content">
128
+
<p className="quoted-post-text">{quotedPost.text}</p>
129
+
</div>
130
+
</div>
131
+
</div>
132
+
);
133
+
}
134
+
135
+
// Fallback if we don't have the quoted post in our database
64
136
return (
65
137
<div className="post-embed post-embed--record">
66
-
<p>Quoted post: {post.embed.record.uri}</p>
138
+
<div className="quoted-post-missing">
139
+
<span className="missing-icon">📝</span>
140
+
<span className="missing-text">Quoted post not available</span>
141
+
</div>
67
142
</div>
68
143
);
69
144
···
101
176
</div>
102
177
)}
103
178
104
-
<Link to={getPostUrl(postResponse.uri)} className="post-content-link">
105
-
<div className="post-content">
179
+
<div className="post-content">
180
+
<Link to={getPostUrl(postResponse.uri)} className="post-text-link">
106
181
<p className="post-text">{post.text}</p>
107
-
{renderEmbed(post)}
108
-
</div>
109
-
</Link>
182
+
</Link>
183
+
{renderEmbed(post)}
184
+
</div>
110
185
111
186
{postResponse.counts && (
112
187
<div className="post-engagement">
113
188
<div className="engagement-stats">
114
189
<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}
190
+
className={`stat-item stat-item-clickable ${isLiked ? 'stat-item-liked' : ''}`}
191
+
onClick={handleLike}
192
+
disabled={isLiking}
193
+
title="Like this post"
122
194
>
123
-
<span className="stat-icon">♥</span>
124
-
<span className="stat-count">{postResponse.counts.likes}</span>
195
+
<span className={`stat-icon ${isLiked ? 'stat-icon-liked' : 'stat-icon-unliked'}`}>♥</span>
196
+
<span className="stat-count">{localLikeCount}</span>
125
197
</button>
126
198
<button
127
199
className="stat-item stat-item-clickable"
+135
frontend/src/components/PostComposer.css
+135
frontend/src/components/PostComposer.css
···
1
+
.post-composer-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
+
align-items: center;
10
+
justify-content: center;
11
+
z-index: 1000;
12
+
}
13
+
14
+
.post-composer {
15
+
background: white;
16
+
border-radius: 16px;
17
+
width: 90%;
18
+
max-width: 600px;
19
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
20
+
display: flex;
21
+
flex-direction: column;
22
+
}
23
+
24
+
.post-composer-header {
25
+
display: flex;
26
+
justify-content: space-between;
27
+
align-items: center;
28
+
padding: 16px 20px;
29
+
border-bottom: 1px solid #e1e8ed;
30
+
}
31
+
32
+
.post-composer-header h2 {
33
+
margin: 0;
34
+
font-size: 20px;
35
+
font-weight: 700;
36
+
color: #0f1419;
37
+
}
38
+
39
+
.close-button {
40
+
background: none;
41
+
border: none;
42
+
font-size: 32px;
43
+
color: #536471;
44
+
cursor: pointer;
45
+
padding: 0;
46
+
width: 32px;
47
+
height: 32px;
48
+
display: flex;
49
+
align-items: center;
50
+
justify-content: center;
51
+
border-radius: 50%;
52
+
transition: background-color 0.2s;
53
+
}
54
+
55
+
.close-button:hover {
56
+
background-color: #f7f9fa;
57
+
}
58
+
59
+
.post-composer-body {
60
+
padding: 20px;
61
+
display: flex;
62
+
flex-direction: column;
63
+
gap: 16px;
64
+
}
65
+
66
+
.post-composer-textarea {
67
+
width: 100%;
68
+
min-height: 150px;
69
+
border: none;
70
+
outline: none;
71
+
resize: vertical;
72
+
font-size: 16px;
73
+
font-family: inherit;
74
+
line-height: 1.5;
75
+
color: #0f1419;
76
+
}
77
+
78
+
.post-composer-textarea::placeholder {
79
+
color: #657786;
80
+
}
81
+
82
+
.post-composer-footer {
83
+
display: flex;
84
+
justify-content: space-between;
85
+
align-items: center;
86
+
}
87
+
88
+
.character-count {
89
+
font-size: 13px;
90
+
color: #657786;
91
+
}
92
+
93
+
.send-button {
94
+
background-color: #1da1f2;
95
+
color: white;
96
+
border: none;
97
+
border-radius: 20px;
98
+
padding: 10px 24px;
99
+
font-size: 15px;
100
+
font-weight: 600;
101
+
cursor: pointer;
102
+
transition: background-color 0.2s;
103
+
}
104
+
105
+
.send-button:hover:not(:disabled) {
106
+
background-color: #1a91da;
107
+
}
108
+
109
+
.send-button:disabled {
110
+
background-color: #8ed0f9;
111
+
cursor: not-allowed;
112
+
}
113
+
114
+
.floating-post-button {
115
+
position: fixed;
116
+
bottom: 24px;
117
+
right: 24px;
118
+
background-color: #1da1f2;
119
+
color: white;
120
+
border: none;
121
+
border-radius: 50px;
122
+
padding: 16px 32px;
123
+
font-size: 16px;
124
+
font-weight: 700;
125
+
cursor: pointer;
126
+
box-shadow: 0 4px 12px rgba(29, 161, 242, 0.4);
127
+
transition: all 0.2s;
128
+
z-index: 100;
129
+
}
130
+
131
+
.floating-post-button:hover {
132
+
background-color: #1a91da;
133
+
box-shadow: 0 6px 16px rgba(29, 161, 242, 0.5);
134
+
transform: translateY(-2px);
135
+
}
+68
frontend/src/components/PostComposer.tsx
+68
frontend/src/components/PostComposer.tsx
···
1
+
import React, { useState } from 'react';
2
+
import { ApiClient } from '../api';
3
+
import './PostComposer.css';
4
+
5
+
interface PostComposerProps {
6
+
onClose: () => void;
7
+
onPostCreated?: () => void;
8
+
}
9
+
10
+
export const PostComposer: React.FC<PostComposerProps> = ({ onClose, onPostCreated }) => {
11
+
const [text, setText] = useState('');
12
+
const [isPosting, setIsPosting] = useState(false);
13
+
14
+
const handlePost = async () => {
15
+
if (!text.trim() || isPosting) return;
16
+
17
+
setIsPosting(true);
18
+
try {
19
+
await ApiClient.createPost(text);
20
+
onClose();
21
+
if (onPostCreated) {
22
+
onPostCreated();
23
+
}
24
+
} catch (err) {
25
+
console.error('Failed to create post:', err);
26
+
alert('Failed to create post. Please try again.');
27
+
} finally {
28
+
setIsPosting(false);
29
+
}
30
+
};
31
+
32
+
const handleBackdropClick = (e: React.MouseEvent) => {
33
+
if (e.target === e.currentTarget) {
34
+
onClose();
35
+
}
36
+
};
37
+
38
+
return (
39
+
<div className="post-composer-backdrop" onClick={handleBackdropClick}>
40
+
<div className="post-composer">
41
+
<div className="post-composer-header">
42
+
<h2>Create Post</h2>
43
+
<button className="close-button" onClick={onClose}>×</button>
44
+
</div>
45
+
<div className="post-composer-body">
46
+
<textarea
47
+
className="post-composer-textarea"
48
+
placeholder="What's happening?"
49
+
value={text}
50
+
onChange={(e) => setText(e.target.value)}
51
+
autoFocus
52
+
maxLength={300}
53
+
/>
54
+
<div className="post-composer-footer">
55
+
<span className="character-count">{text.length}/300</span>
56
+
<button
57
+
className="send-button"
58
+
onClick={handlePost}
59
+
disabled={!text.trim() || isPosting}
60
+
>
61
+
{isPosting ? 'Posting...' : 'Send'}
62
+
</button>
63
+
</div>
64
+
</div>
65
+
</div>
66
+
</div>
67
+
);
68
+
};
+2
-2
frontend/src/components/PostView.tsx
+2
-2
frontend/src/components/PostView.tsx
···
24
24
setError(null);
25
25
26
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 => {
27
+
const profilePostsData = await ApiClient.getProfilePosts(account);
28
+
const targetPost = profilePostsData.posts.find(p => {
29
29
const uriParts = p.uri.split('/');
30
30
return uriParts[uriParts.length - 1] === rkey;
31
31
});
+58
-6
frontend/src/components/ProfilePage.tsx
+58
-6
frontend/src/components/ProfilePage.tsx
···
1
-
import React, { useState, useEffect } from 'react';
1
+
import React, { useState, useEffect, useRef } from 'react';
2
2
import { useParams } from 'react-router-dom';
3
3
import { ActorProfile, PostResponse } from '../types';
4
4
import { ApiClient } from '../api';
···
11
11
const [profile, setProfile] = useState<ActorProfile | null>(null);
12
12
const [posts, setPosts] = useState<PostResponse[]>([]);
13
13
const [loading, setLoading] = useState(true);
14
+
const [loadingMore, setLoadingMore] = useState(false);
14
15
const [error, setError] = useState<string | null>(null);
15
16
const [userDid, setUserDid] = useState<string | null>(null);
17
+
const [cursor, setCursor] = useState<string | null>(null);
18
+
const [hasMore, setHasMore] = useState(true);
19
+
const observerTarget = useRef<HTMLDivElement>(null);
16
20
17
21
useEffect(() => {
18
22
// Scroll to top when navigating to a profile
···
24
28
try {
25
29
setLoading(true);
26
30
setError(null);
31
+
setPosts([]);
32
+
setCursor(null);
33
+
setHasMore(true);
27
34
28
35
// Always try to load posts, regardless of profile status
29
36
const postsPromise = ApiClient.getProfilePosts(account);
···
31
38
32
39
const [profileData, postsData] = await Promise.all([
33
40
profilePromise.catch(() => ({ error: 'Profile not found' })),
34
-
postsPromise.catch(() => [])
41
+
postsPromise.catch(() => ({ posts: [], cursor: '' }))
35
42
]);
36
43
37
44
if ('error' in profileData) {
···
41
48
setProfile(profileData);
42
49
}
43
50
44
-
setPosts(Array.isArray(postsData) ? postsData : []);
51
+
setPosts(postsData.posts || []);
52
+
setCursor(postsData.cursor || null);
53
+
setHasMore(!!(postsData.cursor && postsData.posts && postsData.posts.length > 0));
45
54
46
55
// 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);
56
+
if (postsData.posts && postsData.posts.length > 0 && postsData.posts[0].author) {
57
+
setUserDid(postsData.posts[0].author.did);
49
58
}
50
59
} catch (err) {
51
60
setError(err instanceof Error ? err.message : 'Failed to load data');
···
57
66
fetchProfile();
58
67
}, [account]);
59
68
69
+
const fetchMorePosts = async (cursor: string) => {
70
+
if (!account || loadingMore || !hasMore) return;
71
+
72
+
try {
73
+
setLoadingMore(true);
74
+
const data = await ApiClient.getProfilePosts(account, cursor);
75
+
setPosts(prev => [...prev, ...data.posts]);
76
+
setCursor(data.cursor || null);
77
+
setHasMore(!!(data.cursor && data.posts.length > 0));
78
+
} catch (err) {
79
+
console.error('Failed to fetch more posts:', err);
80
+
} finally {
81
+
setLoadingMore(false);
82
+
}
83
+
};
84
+
85
+
useEffect(() => {
86
+
const observer = new IntersectionObserver(
87
+
(entries) => {
88
+
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
89
+
if (cursor) {
90
+
fetchMorePosts(cursor);
91
+
}
92
+
}
93
+
},
94
+
{ threshold: 0.1 }
95
+
);
96
+
97
+
if (observerTarget.current) {
98
+
observer.observe(observerTarget.current);
99
+
}
100
+
101
+
return () => {
102
+
if (observerTarget.current) {
103
+
observer.unobserve(observerTarget.current);
104
+
}
105
+
};
106
+
}, [hasMore, loadingMore, loading, cursor]);
107
+
60
108
if (loading) {
61
109
return (
62
110
<div className="profile-page">
···
149
197
{posts.map((post, index) => (
150
198
<PostCard key={post.uri || index} postResponse={post} />
151
199
))}
152
-
{posts.length === 0 && (
200
+
{posts.length === 0 && !loading && (
153
201
<div className="empty-posts">
154
202
<p>No posts yet</p>
155
203
</div>
204
+
)}
205
+
{hasMore && <div ref={observerTarget} style={{ height: '20px' }} />}
206
+
{loadingMore && (
207
+
<div className="loading-more">Loading more posts...</div>
156
208
)}
157
209
</div>
158
210
</div>
+12
-1
frontend/src/types.ts
+12
-1
frontend/src/types.ts
···
47
47
};
48
48
}
49
49
50
+
export interface EmbedRecordView {
51
+
$type: string;
52
+
uri: string;
53
+
cid: string;
54
+
author?: AuthorInfo;
55
+
value?: FeedPost;
56
+
indexedAt?: string;
57
+
}
58
+
50
59
export interface EmbedRecord {
51
60
$type: "app.bsky.embed.record";
52
-
record: {
61
+
record: EmbedRecordView | {
53
62
cid: string;
54
63
uri: string;
55
64
};
···
81
90
export interface PostResponse {
82
91
missing: boolean;
83
92
uri: string;
93
+
cid: string;
84
94
post?: FeedPost;
85
95
author?: AuthorInfo;
86
96
counts?: PostCounts;
···
88
98
replyTo?: number;
89
99
replyToUsr?: number;
90
100
inThread?: number;
101
+
viewerLike?: string;
91
102
}
92
103
93
104
export interface ThreadResponse {
+276
-89
handlers.go
+276
-89
handlers.go
···
3
3
import (
4
4
"bytes"
5
5
"context"
6
+
"encoding/json"
6
7
"fmt"
7
8
"log/slog"
8
9
"sync"
···
10
11
11
12
"github.com/bluesky-social/indigo/api/bsky"
12
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"github.com/bluesky-social/indigo/xrpc"
13
15
"github.com/labstack/echo/v4"
14
16
"github.com/labstack/echo/v4/middleware"
15
17
"github.com/labstack/gommon/log"
···
21
23
e := echo.New()
22
24
e.Use(middleware.CORS())
23
25
e.GET("/debug", s.handleGetDebugInfo)
26
+
24
27
views := e.Group("/api")
25
28
views.GET("/profile/:account/post/:rkey", s.handleGetPost)
26
29
views.GET("/profile/:account", s.handleGetProfileView)
···
30
33
views.GET("/post/:postid/likes", s.handleGetPostLikes)
31
34
views.GET("/post/:postid/reposts", s.handleGetPostReposts)
32
35
views.GET("/post/:postid/replies", s.handleGetPostReplies)
36
+
views.POST("/createRecord", s.handleCreateRecord)
33
37
34
38
return e.Start(":4444")
35
39
}
···
126
130
return err
127
131
}
128
132
129
-
var dbposts []models.Post
130
-
if err := s.backend.db.Find(&dbposts, "author = ?", r.ID).Error; err != nil {
131
-
return err
132
-
}
133
+
// Get cursor from query parameter (timestamp in RFC3339 format)
134
+
cursor := e.QueryParam("cursor")
135
+
limit := 20
133
136
134
-
author, err := s.getAuthorInfo(ctx, r)
135
-
if err != nil {
136
-
slog.Error("failed to load author info for post", "error", err)
137
+
tcursor := time.Now()
138
+
if cursor != "" {
139
+
t, err := time.Parse(time.RFC3339, cursor)
140
+
if err != nil {
141
+
return fmt.Errorf("invalid cursor: %w", err)
142
+
}
143
+
tcursor = t
137
144
}
138
145
139
-
posts := []postResponse{}
140
-
for _, p := range dbposts {
141
-
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey)
142
-
if len(p.Raw) == 0 || p.NotFound {
143
-
posts = append(posts, postResponse{
144
-
Uri: uri,
145
-
Missing: true,
146
-
})
147
-
continue
148
-
}
149
-
var fp bsky.FeedPost
150
-
if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err != nil {
151
-
return err
152
-
}
146
+
var dbposts []models.Post
147
+
if err := s.backend.db.Raw("SELECT * FROM posts WHERE author = ? AND created < ? ORDER BY created DESC LIMIT ?", r.ID, tcursor, limit).Scan(&dbposts).Error; err != nil {
148
+
return err
149
+
}
153
150
154
-
counts, err := s.getPostCounts(ctx, p.ID)
155
-
if err != nil {
156
-
slog.Error("failed to get counts for post", "post", p.ID, "error", err)
157
-
}
151
+
posts := s.hydratePosts(ctx, dbposts)
158
152
159
-
posts = append(posts, postResponse{
160
-
Uri: uri,
161
-
Post: &fp,
162
-
AuthorInfo: author,
163
-
Counts: counts,
164
-
ID: p.ID,
165
-
ReplyTo: p.ReplyTo,
166
-
ReplyToUsr: p.ReplyToUsr,
167
-
InThread: p.InThread,
168
-
})
153
+
// Generate next cursor from the last post's timestamp
154
+
var nextCursor string
155
+
if len(dbposts) > 0 {
156
+
nextCursor = dbposts[len(dbposts)-1].Created.Format(time.RFC3339)
169
157
}
170
158
171
-
return e.JSON(200, posts)
159
+
return e.JSON(200, map[string]any{
160
+
"posts": posts,
161
+
"cursor": nextCursor,
162
+
})
172
163
}
173
164
174
165
type postCounts struct {
···
177
168
Replies int `json:"replies"`
178
169
}
179
170
171
+
type embedRecordView struct {
172
+
Type string `json:"$type"`
173
+
Uri string `json:"uri"`
174
+
Cid string `json:"cid"`
175
+
Author *authorInfo `json:"author,omitempty"`
176
+
Value *bsky.FeedPost `json:"value,omitempty"`
177
+
}
178
+
179
+
type viewerLike struct {
180
+
Uri string `json:"uri"`
181
+
Cid string `json:"cid"`
182
+
}
183
+
180
184
type postResponse struct {
181
-
Missing bool `json:"missing"`
182
-
Uri string `json:"uri"`
183
-
Post *bsky.FeedPost `json:"post"`
184
-
AuthorInfo *authorInfo `json:"author"`
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"`
185
+
Missing bool `json:"missing"`
186
+
Uri string `json:"uri"`
187
+
Cid string `json:"cid"`
188
+
Post *feedPostView `json:"post"`
189
+
AuthorInfo *authorInfo `json:"author"`
190
+
Counts *postCounts `json:"counts"`
191
+
ViewerLike *viewerLike `json:"viewerLike,omitempty"`
192
+
193
+
ID uint `json:"id"`
194
+
ReplyTo uint `json:"replyTo,omitempty"`
195
+
ReplyToUsr uint `json:"replyToUsr,omitempty"`
196
+
InThread uint `json:"inThread,omitempty"`
197
+
}
198
+
199
+
type feedPostView struct {
200
+
Type string `json:"$type"`
201
+
CreatedAt string `json:"createdAt"`
202
+
Langs []string `json:"langs,omitempty"`
203
+
Text string `json:"text"`
204
+
Facets interface{} `json:"facets,omitempty"`
205
+
Embed interface{} `json:"embed,omitempty"`
190
206
}
191
207
192
208
type authorInfo struct {
···
220
236
return err
221
237
}
222
238
239
+
posts := s.hydratePosts(ctx, dbposts)
240
+
241
+
// Generate next cursor from the last post's timestamp
242
+
var nextCursor string
243
+
if len(dbposts) > 0 {
244
+
nextCursor = dbposts[len(dbposts)-1].Created.Format(time.RFC3339)
245
+
}
246
+
247
+
return e.JSON(200, map[string]any{
248
+
"posts": posts,
249
+
"cursor": nextCursor,
250
+
})
251
+
}
252
+
253
+
func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) {
254
+
var profile models.Profile
255
+
if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil {
256
+
return nil, err
257
+
}
258
+
259
+
resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did))
260
+
if err != nil {
261
+
return nil, err
262
+
}
263
+
264
+
if profile.Raw == nil || len(profile.Raw) == 0 {
265
+
s.addMissingProfile(ctx, r.Did)
266
+
return &authorInfo{
267
+
Handle: resp.Handle.String(),
268
+
Did: r.Did,
269
+
}, nil
270
+
}
271
+
272
+
var prof bsky.ActorProfile
273
+
if err := prof.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err != nil {
274
+
return nil, err
275
+
}
276
+
277
+
return &authorInfo{
278
+
Handle: resp.Handle.String(),
279
+
Did: r.Did,
280
+
Profile: &prof,
281
+
}, nil
282
+
}
283
+
284
+
func (s *Server) getPostCounts(ctx context.Context, pid uint) (*postCounts, error) {
285
+
var pc postCounts
286
+
var wg sync.WaitGroup
287
+
288
+
wg.Add(3)
289
+
290
+
go func() {
291
+
defer wg.Done()
292
+
if err := s.backend.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil {
293
+
slog.Error("failed to get likes count", "post", pid, "error", err)
294
+
}
295
+
}()
296
+
297
+
go func() {
298
+
defer wg.Done()
299
+
if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil {
300
+
slog.Error("failed to get reposts count", "post", pid, "error", err)
301
+
}
302
+
}()
303
+
304
+
go func() {
305
+
defer wg.Done()
306
+
if err := s.backend.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil {
307
+
slog.Error("failed to get replies count", "post", pid, "error", err)
308
+
}
309
+
}()
310
+
311
+
wg.Wait()
312
+
313
+
return &pc, nil
314
+
}
315
+
316
+
func (s *Server) hydratePosts(ctx context.Context, dbposts []models.Post) []postResponse {
223
317
posts := make([]postResponse, len(dbposts))
224
318
var wg sync.WaitGroup
225
319
···
267
361
slog.Error("failed to get counts for post", "post", p.ID, "error", err)
268
362
}
269
363
364
+
// Build post view with hydrated embeds
365
+
postView := s.buildPostView(ctx, &fp)
366
+
367
+
viewerLike := s.checkViewerLike(ctx, p.ID)
368
+
270
369
posts[ix] = postResponse{
271
370
Uri: uri,
272
-
Post: &fp,
371
+
Cid: p.Cid,
372
+
Post: postView,
273
373
AuthorInfo: author,
274
374
Counts: counts,
275
375
ID: p.ID,
276
376
ReplyTo: p.ReplyTo,
277
377
ReplyToUsr: p.ReplyToUsr,
278
378
InThread: p.InThread,
379
+
380
+
ViewerLike: viewerLike,
279
381
}
280
382
}(i)
281
383
}
282
384
283
385
wg.Wait()
284
386
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
-
})
387
+
return posts
295
388
}
296
389
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
390
+
func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike {
391
+
var like Like
392
+
if err := s.backend.db.Raw("SELECT * FROM likes WHERE subject = ? AND author = ?", pid, s.myrepo.ID).Scan(&like).Error; err != nil {
393
+
slog.Error("failed to lookup like", "error", err)
394
+
return nil
301
395
}
302
396
303
-
resp, err := s.dir.LookupDID(ctx, syntax.DID(r.Did))
304
-
if err != nil {
305
-
return nil, err
397
+
if like.ID == 0 {
398
+
return nil
306
399
}
307
400
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
-
}
401
+
uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", s.myrepo.Did, like.Rkey)
315
402
316
-
var prof bsky.ActorProfile
317
-
if err := prof.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err != nil {
318
-
return nil, err
403
+
return &viewerLike{
404
+
Uri: uri,
405
+
Cid: like.Cid,
319
406
}
320
-
321
-
return &authorInfo{
322
-
Handle: resp.Handle.String(),
323
-
Did: r.Did,
324
-
Profile: &prof,
325
-
}, nil
326
407
}
327
408
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
409
+
func (s *Server) buildPostView(ctx context.Context, fp *bsky.FeedPost) *feedPostView {
410
+
view := &feedPostView{
411
+
Type: fp.LexiconTypeID,
412
+
CreatedAt: fp.CreatedAt,
413
+
Text: fp.Text,
414
+
Facets: fp.Facets,
332
415
}
333
-
if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil {
334
-
return nil, err
416
+
417
+
if fp.Langs != nil {
418
+
view.Langs = fp.Langs
335
419
}
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
420
+
421
+
// Hydrate embed if present
422
+
if fp.Embed != nil {
423
+
slog.Info("processing embed", "hasImages", fp.Embed.EmbedImages != nil, "hasExternal", fp.Embed.EmbedExternal != nil, "hasRecord", fp.Embed.EmbedRecord != nil)
424
+
if fp.Embed.EmbedImages != nil {
425
+
view.Embed = fp.Embed.EmbedImages
426
+
} else if fp.Embed.EmbedExternal != nil {
427
+
view.Embed = fp.Embed.EmbedExternal
428
+
} else if fp.Embed.EmbedRecord != nil {
429
+
// Hydrate quoted post
430
+
quotedURI := fp.Embed.EmbedRecord.Record.Uri
431
+
quotedCid := fp.Embed.EmbedRecord.Record.Cid
432
+
slog.Info("hydrating quoted post", "uri", quotedURI, "cid", quotedCid)
433
+
434
+
quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*")
435
+
if err != nil {
436
+
slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err)
437
+
}
438
+
if err == nil && quotedPost != nil && quotedPost.Raw != nil && len(quotedPost.Raw) > 0 && !quotedPost.NotFound {
439
+
slog.Info("found quoted post, hydrating")
440
+
var quotedFP bsky.FeedPost
441
+
if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err == nil {
442
+
quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author)
443
+
if err == nil {
444
+
quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo)
445
+
if err == nil {
446
+
view.Embed = map[string]interface{}{
447
+
"$type": "app.bsky.embed.record",
448
+
"record": &embedRecordView{
449
+
Type: "app.bsky.embed.record#viewRecord",
450
+
Uri: quotedURI,
451
+
Cid: quotedCid,
452
+
Author: quotedAuthor,
453
+
Value: "edFP,
454
+
},
455
+
}
456
+
}
457
+
}
458
+
}
459
+
}
460
+
461
+
// Fallback if hydration failed - show basic info
462
+
if view.Embed == nil {
463
+
slog.Info("quoted post not in database, using fallback")
464
+
view.Embed = map[string]interface{}{
465
+
"$type": "app.bsky.embed.record",
466
+
"record": map[string]interface{}{
467
+
"uri": quotedURI,
468
+
"cid": quotedCid,
469
+
},
470
+
}
471
+
}
472
+
}
338
473
}
339
474
340
-
return &pc, nil
475
+
return view
341
476
}
342
477
343
478
func (s *Server) handleGetThread(e echo.Context) error {
···
411
546
slog.Error("failed to get counts for post", "post", p.ID, "error", err)
412
547
}
413
548
549
+
// Build post view with hydrated embeds
550
+
postView := s.buildPostView(ctx, &fp)
551
+
414
552
posts = append(posts, postResponse{
415
553
Uri: uri,
416
-
Post: &fp,
554
+
Cid: p.Cid,
555
+
Post: postView,
417
556
AuthorInfo: author,
418
557
Counts: counts,
419
558
ID: p.ID,
···
617
756
"count": len(users),
618
757
})
619
758
}
759
+
760
+
type createRecordRequest struct {
761
+
Collection string `json:"collection"`
762
+
Record map[string]any `json:"record"`
763
+
}
764
+
765
+
type createRecordResponse struct {
766
+
Uri string `json:"uri"`
767
+
Cid string `json:"cid"`
768
+
}
769
+
770
+
func (s *Server) handleCreateRecord(e echo.Context) error {
771
+
ctx := e.Request().Context()
772
+
773
+
var req createRecordRequest
774
+
if err := e.Bind(&req); err != nil {
775
+
return e.JSON(400, map[string]any{
776
+
"error": "invalid request",
777
+
})
778
+
}
779
+
780
+
// Marshal the record to JSON for XRPC
781
+
recordBytes, err := json.Marshal(req.Record)
782
+
if err != nil {
783
+
slog.Error("failed to marshal record", "error", err)
784
+
return e.JSON(400, map[string]any{
785
+
"error": "invalid record",
786
+
})
787
+
}
788
+
789
+
// Create the input for the repo.createRecord call
790
+
input := map[string]any{
791
+
"repo": s.mydid,
792
+
"collection": req.Collection,
793
+
"record": json.RawMessage(recordBytes),
794
+
}
795
+
796
+
var resp createRecordResponse
797
+
if err := s.client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil {
798
+
slog.Error("failed to create record", "error", err)
799
+
return e.JSON(500, map[string]any{
800
+
"error": "failed to create record",
801
+
"details": err.Error(),
802
+
})
803
+
}
804
+
805
+
return e.JSON(200, resp)
806
+
}
+9
-2
main.go
+9
-2
main.go
···
36
36
"github.com/prometheus/client_golang/prometheus/promauto"
37
37
"github.com/urfave/cli/v2"
38
38
"github.com/whyrusleeping/market/models"
39
-
. "github.com/whyrusleeping/market/models"
40
39
"gorm.io/gorm"
41
40
"gorm.io/gorm/clause"
42
41
"gorm.io/gorm/logger"
···
173
172
}
174
173
s.backend = pgb
175
174
175
+
myrepo, err := s.backend.getOrCreateRepo(ctx, mydid)
176
+
if err != nil {
177
+
return fmt.Errorf("failed to get repo record for our own did: %w", err)
178
+
}
179
+
s.myrepo = myrepo
180
+
176
181
if err := s.backend.loadRelevantDids(); err != nil {
177
182
return fmt.Errorf("failed to load relevant dids set: %w", err)
178
183
}
···
207
212
208
213
client *xrpc.Client
209
214
mydid string
215
+
myrepo *Repo
210
216
211
217
seqLk sync.Mutex
212
218
lastSeq int64
···
870
876
Author: repo.ID,
871
877
Rkey: rkey,
872
878
Raw: recb,
879
+
Cid: cc.String(),
873
880
}
874
881
875
882
if rec.Reply != nil && rec.Reply.Parent != nil {
···
1022
1029
return fmt.Errorf("getting like subject: %w", err)
1023
1030
}
1024
1031
1025
-
if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject") VALUES ($1, $2, $3, $4, $5)`, created.Time(), time.Now(), repo.ID, rkey, pid); err != nil {
1032
+
if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject","cid") VALUES ($1, $2, $3, $4, $5, $6)`, created.Time(), time.Now(), repo.ID, rkey, pid, cc.String()); err != nil {
1026
1033
pgErr, ok := err.(*pgconn.PgError)
1027
1034
if ok && pgErr.Code == "23505" {
1028
1035
return nil
+32
models.go
+32
models.go
···
1
+
package main
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/whyrusleeping/market/models"
7
+
)
8
+
9
+
type Repo = models.Repo
10
+
type Post = models.Post
11
+
type Follow = models.Follow
12
+
type Block = models.Block
13
+
type Repost = models.Repost
14
+
type List = models.List
15
+
type ListItem = models.ListItem
16
+
type ListBlock = models.ListBlock
17
+
type Profile = models.Profile
18
+
type ThreadGate = models.ThreadGate
19
+
type FeedGenerator = models.FeedGenerator
20
+
type Image = models.Image
21
+
type PostGate = models.PostGate
22
+
type StarterPack = models.StarterPack
23
+
24
+
type Like struct {
25
+
ID uint `gorm:"primarykey"`
26
+
Created time.Time
27
+
Indexed time.Time
28
+
Author uint `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
29
+
Rkey string `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
30
+
Subject uint
31
+
Cid string
32
+
}