A locally focused bluesky appview

switch sequence number tracking to database, other changes

Changed files
+163 -70
frontend
src
components
+42
frontend/src/components/PostView.tsx
··· 3 3 import { PostResponse } from '../types'; 4 4 import { ApiClient } from '../api'; 5 5 import { PostCard } from './PostCard'; 6 + import { EngagementModal } from './EngagementModal'; 6 7 import './PostView.css'; 7 8 8 9 export const PostView: React.FC = () => { ··· 11 12 const [threadPosts, setThreadPosts] = useState<PostResponse[]>([]); 12 13 const [loading, setLoading] = useState(true); 13 14 const [error, setError] = useState<string | null>(null); 15 + const [showEngagementModal, setShowEngagementModal] = useState<'likes' | 'reposts' | 'replies' | null>(null); 14 16 15 17 useEffect(() => { 16 18 // Scroll to top when navigating to a post ··· 96 98 <PostCard postResponse={mainPost} showThreadIndicator={false} /> 97 99 </div> 98 100 101 + {mainPost.counts && (mainPost.counts.likes > 0 || mainPost.counts.reposts > 0 || mainPost.counts.replies > 0) && ( 102 + <div className="post-engagement-detail"> 103 + {mainPost.counts.likes > 0 && ( 104 + <button 105 + className="engagement-detail-item" 106 + onClick={() => setShowEngagementModal('likes')} 107 + > 108 + <span className="engagement-detail-count">{mainPost.counts.likes}</span> 109 + <span className="engagement-detail-label">{mainPost.counts.likes === 1 ? 'Like' : 'Likes'}</span> 110 + </button> 111 + )} 112 + {mainPost.counts.reposts > 0 && ( 113 + <button 114 + className="engagement-detail-item" 115 + onClick={() => setShowEngagementModal('reposts')} 116 + > 117 + <span className="engagement-detail-count">{mainPost.counts.reposts}</span> 118 + <span className="engagement-detail-label">{mainPost.counts.reposts === 1 ? 'Repost' : 'Reposts'}</span> 119 + </button> 120 + )} 121 + {mainPost.counts.replies > 0 && ( 122 + <button 123 + className="engagement-detail-item" 124 + onClick={() => setShowEngagementModal('replies')} 125 + > 126 + <span className="engagement-detail-count">{mainPost.counts.replies}</span> 127 + <span className="engagement-detail-label">{mainPost.counts.replies === 1 ? 'Reply' : 'Replies'}</span> 128 + </button> 129 + )} 130 + </div> 131 + )} 132 + 99 133 {threadPosts.length > 0 && ( 100 134 <div className="thread-replies"> 101 135 <div className="replies-header"> ··· 109 143 </div> 110 144 )} 111 145 </div> 146 + 147 + {showEngagementModal && ( 148 + <EngagementModal 149 + postId={mainPost.id} 150 + type={showEngagementModal} 151 + onClose={() => setShowEngagementModal(null)} 152 + /> 153 + )} 112 154 </div> 113 155 ); 114 156 };
+92 -49
handlers.go
··· 437 437 view.Langs = fp.Langs 438 438 } 439 439 440 - // Hydrate embed if present 441 440 if fp.Embed != nil { 442 - slog.Info("processing embed", "hasImages", fp.Embed.EmbedImages != nil, "hasExternal", fp.Embed.EmbedExternal != nil, "hasRecord", fp.Embed.EmbedRecord != nil) 443 - if fp.Embed.EmbedImages != nil { 444 - view.Embed = fp.Embed.EmbedImages 445 - } else if fp.Embed.EmbedExternal != nil { 446 - view.Embed = fp.Embed.EmbedExternal 447 - } else if fp.Embed.EmbedRecord != nil { 448 - // Hydrate quoted post 449 - quotedURI := fp.Embed.EmbedRecord.Record.Uri 450 - quotedCid := fp.Embed.EmbedRecord.Record.Cid 451 - slog.Info("hydrating quoted post", "uri", quotedURI, "cid", quotedCid) 441 + view.Embed = s.hydrateEmbed(ctx, fp.Embed) 442 + } 443 + 444 + return view 445 + } 452 446 453 - quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*") 454 - if err != nil { 455 - slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err) 456 - } 457 - if err == nil && quotedPost != nil && quotedPost.Raw != nil && len(quotedPost.Raw) > 0 && !quotedPost.NotFound { 458 - slog.Info("found quoted post, hydrating") 459 - var quotedFP bsky.FeedPost 460 - if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err == nil { 461 - quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author) 462 - if err == nil { 463 - quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo) 464 - if err == nil { 465 - view.Embed = map[string]interface{}{ 466 - "$type": "app.bsky.embed.record", 467 - "record": &embedRecordView{ 468 - Type: "app.bsky.embed.record#viewRecord", 469 - Uri: quotedURI, 470 - Cid: quotedCid, 471 - Author: quotedAuthor, 472 - Value: &quotedFP, 473 - }, 474 - } 475 - } 476 - } 477 - } 478 - } 447 + func (s *Server) hydrateEmbed(ctx context.Context, embed *bsky.FeedPost_Embed) interface{} { 448 + switch { 449 + case embed.EmbedImages != nil: 450 + return embed.EmbedImages 451 + case embed.EmbedExternal != nil: 452 + return embed.EmbedExternal 453 + case embed.EmbedRecord != nil: 454 + return s.hydrateQuotedPost(ctx, embed.EmbedRecord) 455 + case embed.EmbedRecordWithMedia != nil: 456 + return s.hydrateRecordWithMedia(ctx, embed.EmbedRecordWithMedia) 457 + default: 458 + return nil 459 + } 460 + } 461 + 462 + func (s *Server) hydrateRecordWithMedia(ctx context.Context, rwm *bsky.EmbedRecordWithMedia) interface{} { 463 + result := map[string]interface{}{ 464 + "$type": "app.bsky.embed.recordWithMedia", 465 + } 479 466 480 - // Fallback if hydration failed - show basic info 481 - if view.Embed == nil { 482 - slog.Info("quoted post not in database, using fallback") 483 - view.Embed = map[string]interface{}{ 484 - "$type": "app.bsky.embed.record", 485 - "record": map[string]interface{}{ 486 - "uri": quotedURI, 487 - "cid": quotedCid, 488 - }, 489 - } 490 - } 467 + // Hydrate media 468 + if rwm.Media != nil { 469 + if rwm.Media.EmbedImages != nil { 470 + result["media"] = rwm.Media.EmbedImages 471 + } else if rwm.Media.EmbedExternal != nil { 472 + result["media"] = rwm.Media.EmbedExternal 491 473 } 492 474 } 493 475 494 - return view 476 + // Hydrate record 477 + if rwm.Record != nil { 478 + result["record"] = s.hydrateQuotedPost(ctx, rwm.Record) 479 + } 480 + 481 + return result 482 + } 483 + 484 + func (s *Server) hydrateQuotedPost(ctx context.Context, embedRecord *bsky.EmbedRecord) interface{} { 485 + quotedURI := embedRecord.Record.Uri 486 + quotedCid := embedRecord.Record.Cid 487 + 488 + quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*") 489 + if err != nil { 490 + slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err) 491 + s.addMissingPost(ctx, quotedURI) 492 + return s.buildQuoteFallback(quotedURI, quotedCid) 493 + } 494 + 495 + if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound { 496 + s.addMissingPost(ctx, quotedURI) 497 + return s.buildQuoteFallback(quotedURI, quotedCid) 498 + } 499 + 500 + var quotedFP bsky.FeedPost 501 + if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err != nil { 502 + slog.Warn("failed to unmarshal quoted post", "error", err) 503 + return s.buildQuoteFallback(quotedURI, quotedCid) 504 + } 505 + 506 + quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author) 507 + if err != nil { 508 + slog.Warn("failed to get quoted post author", "error", err) 509 + return s.buildQuoteFallback(quotedURI, quotedCid) 510 + } 511 + 512 + quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo) 513 + if err != nil { 514 + slog.Warn("failed to get quoted post author info", "error", err) 515 + return s.buildQuoteFallback(quotedURI, quotedCid) 516 + } 517 + 518 + return map[string]interface{}{ 519 + "$type": "app.bsky.embed.record", 520 + "record": &embedRecordView{ 521 + Type: "app.bsky.embed.record#viewRecord", 522 + Uri: quotedURI, 523 + Cid: quotedCid, 524 + Author: quotedAuthor, 525 + Value: &quotedFP, 526 + }, 527 + } 528 + } 529 + 530 + func (s *Server) buildQuoteFallback(uri, cid string) map[string]interface{} { 531 + return map[string]interface{}{ 532 + "$type": "app.bsky.embed.record", 533 + "record": map[string]interface{}{ 534 + "uri": uri, 535 + "cid": cid, 536 + }, 537 + } 495 538 } 496 539 497 540 func (s *Server) handleGetThread(e echo.Context) error {
+4 -3
main.go
··· 88 88 db.AutoMigrate(StarterPack{}) 89 89 db.AutoMigrate(SyncInfo{}) 90 90 db.AutoMigrate(Notification{}) 91 + db.AutoMigrate(SequenceTracker{}) 91 92 92 93 ctx := context.TODO() 93 94 ··· 186 187 go s.missingProfileFetcher() 187 188 go s.missingPostFetcher() 188 189 189 - seqno, err := loadLastSeq("sequence.txt") 190 + seqno, err := loadLastSeq(db, "firehose_seq") 190 191 if err != nil { 191 192 fmt.Println("failed to load sequence number, starting over", err) 192 193 } 193 194 194 - return s.startLiveTail(ctx, seqno, 10, 20) 195 + return s.startLiveTail(ctx, int(seqno), 10, 20) 195 196 } 196 197 197 198 app.RunAndExitOnError() ··· 267 268 s.lastSeq = evt.Seq 268 269 269 270 if evt.Seq%1000 == 0 { 270 - if err := storeLastSeq("sequence.txt", int(evt.Seq)); err != nil { 271 + if err := storeLastSeq(s.backend.db, "firehose_seq", evt.Seq); err != nil { 271 272 fmt.Println("failed to store seqno: ", err) 272 273 } 273 274 }
+2
missing.go
··· 4 4 "bytes" 5 5 "context" 6 6 "fmt" 7 + "log/slog" 7 8 "strings" 8 9 9 10 "github.com/bluesky-social/indigo/api/atproto" ··· 68 69 } 69 70 70 71 func (s *Server) addMissingPost(ctx context.Context, uri string) { 72 + slog.Info("adding missing post to fetch queue", "uri", uri) 71 73 select { 72 74 case s.missingPosts <- uri: 73 75 case <-ctx.Done():
+6
models.go
··· 40 40 Source string 41 41 Kind string 42 42 } 43 + 44 + type SequenceTracker struct { 45 + ID uint `gorm:"primarykey"` 46 + Key string `gorm:"uniqueIndex"` 47 + IntVal int64 48 + }
+17 -18
seqno.go
··· 1 1 package main 2 2 3 3 import ( 4 - "fmt" 5 - "io/ioutil" 6 - "strconv" 7 - "strings" 4 + "gorm.io/gorm" 5 + "gorm.io/gorm/clause" 8 6 ) 9 7 10 - func storeLastSeq(filename string, seq int) error { 11 - data := fmt.Sprint(seq) 12 - return ioutil.WriteFile(filename, []byte(data), 0644) 8 + func storeLastSeq(db *gorm.DB, key string, seq int64) error { 9 + return db.Clauses(clause.OnConflict{ 10 + Columns: []clause.Column{{Name: "key"}}, 11 + DoUpdates: clause.AssignmentColumns([]string{"int_val"}), 12 + }).Create(&SequenceTracker{ 13 + Key: key, 14 + IntVal: seq, 15 + }).Error 13 16 } 14 17 15 - func loadLastSeq(filename string) (int, error) { 16 - data, err := ioutil.ReadFile(filename) 17 - if err != nil { 18 + func loadLastSeq(db *gorm.DB, key string) (int64, error) { 19 + var info SequenceTracker 20 + if err := db.Where("key = ?", key).First(&info).Error; err != nil { 21 + if err == gorm.ErrRecordNotFound { 22 + return 0, nil 23 + } 18 24 return 0, err 19 25 } 20 - 21 - seqStr := strings.TrimSpace(string(data)) 22 - seq, err := strconv.Atoi(seqStr) 23 - if err != nil { 24 - return 0, err 25 - } 26 - 27 - return seq, nil 26 + return info.IntVal, nil 28 27 }