+42
frontend/src/components/PostView.tsx
+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
+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: "edFP,
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: "edFP,
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
+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
+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
+6
models.go
+17
-18
seqno.go
+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
}