A locally focused bluesky appview

a bunch of stuff, likes, posting, like state. lets goo

+3
.env.example
··· 1 + # Your Bluesky credentials (required) 2 + BSKY_HANDLE=your-handle.bsky.social 3 + BSKY_PASSWORD=your-app-password
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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: &quotedFP, 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
··· 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
··· 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 + }