feat: Implement repost/boost API, profile layout persistence, feed algorithm wiring, and legacy cleanup
Backend: - Add repost_handler.go with full CRUD (create, boost, delete, report, trending, amplification analytics) - Add profile_layout_handler.go for profile widget layout persistence (GET/PUT) - Wire FeedAlgorithmService into main.go as 15-min background score refresh job - Fix follow_handler.go (broken interface, dead query pattern, naming conflict) - Add DB migration for reposts, repost_reports, profile_layouts, post_feed_scores tables - Add engagement count columns to posts table for feed algorithm - Remove stale Supabase comments from auth middleware - Delete cmd/supabase-migrate/ directory (legacy migration tool) Flutter: - Fix all repost_service.dart API paths (were doubling /api/ prefix against base URL) - Rename forceResetBrokenKeys() -> resetIdentityKeys() in E2EE services - Remove dead _forceResetBrokenKeys method from secure_chat_screen.dart - Implement _navigateToProfile(), _navigateToHashtag(), _navigateToUrl() in sojorn_rich_text.dart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
56a9dd032f
commit
c3329a0893
|
|
@ -213,6 +213,13 @@ func main() {
|
|||
cfg.R2VidDomain,
|
||||
)
|
||||
|
||||
// Feed algorithm service (scores posts for ranked feed)
|
||||
feedAlgorithmService := services.NewFeedAlgorithmService(dbPool)
|
||||
|
||||
// Repost & profile layout handlers
|
||||
repostHandler := handlers.NewRepostHandler(dbPool)
|
||||
profileLayoutHandler := handlers.NewProfileLayoutHandler(dbPool)
|
||||
|
||||
r.GET("/ws", wsHandler.ServeWS)
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
|
|
@ -394,15 +401,12 @@ func main() {
|
|||
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
||||
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
|
||||
|
||||
// Follow System
|
||||
// Follow System (unique routes only — followers/following covered by users group above)
|
||||
followHandler := handlers.NewFollowHandler(dbPool)
|
||||
authorized.POST("/users/:userId/follow", followHandler.FollowUser)
|
||||
authorized.POST("/users/:userId/unfollow", followHandler.UnfollowUser)
|
||||
authorized.GET("/users/:userId/is-following", followHandler.IsFollowing)
|
||||
authorized.GET("/users/:userId/mutual-followers", followHandler.GetMutualFollowers)
|
||||
authorized.GET("/users/suggested", followHandler.GetSuggestedUsers)
|
||||
authorized.GET("/users/:userId/followers", followHandler.GetFollowers)
|
||||
authorized.GET("/users/:userId/following", followHandler.GetFollowing)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
||||
|
|
@ -542,6 +546,24 @@ func main() {
|
|||
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
|
||||
}
|
||||
|
||||
// Repost & amplification system
|
||||
authorized.POST("/posts/repost", repostHandler.CreateRepost)
|
||||
authorized.POST("/posts/boost", repostHandler.BoostPost)
|
||||
authorized.GET("/posts/trending", repostHandler.GetTrendingPosts)
|
||||
authorized.GET("/posts/:id/reposts", repostHandler.GetRepostsForPost)
|
||||
authorized.GET("/posts/:id/amplification", repostHandler.GetAmplificationAnalytics)
|
||||
authorized.POST("/posts/:id/calculate-score", repostHandler.CalculateAmplificationScore)
|
||||
authorized.DELETE("/reposts/:id", repostHandler.DeleteRepost)
|
||||
authorized.POST("/reposts/:id/report", repostHandler.ReportRepost)
|
||||
authorized.GET("/amplification/rules", repostHandler.GetAmplificationRules)
|
||||
authorized.GET("/users/:id/reposts", repostHandler.GetUserReposts)
|
||||
authorized.GET("/users/:id/can-boost/:postId", repostHandler.CanBoostPost)
|
||||
authorized.GET("/users/:id/daily-boosts", repostHandler.GetDailyBoostCount)
|
||||
|
||||
// Profile widget layout
|
||||
authorized.GET("/profile/layout", profileLayoutHandler.GetProfileLayout)
|
||||
authorized.PUT("/profile/layout", profileLayoutHandler.SaveProfileLayout)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -696,6 +718,18 @@ func main() {
|
|||
}
|
||||
}()
|
||||
|
||||
// Background job: update feed algorithm scores every 15 minutes
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
log.Debug().Msg("[FeedAlgorithm] Refreshing feed scores")
|
||||
if err := feedAlgorithmService.UpdateFeedScores(context.Background(), []string{}, ""); err != nil {
|
||||
log.Error().Err(err).Msg("[FeedAlgorithm] Failed to refresh feed scores")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Background job: purge accounts past 14-day deletion window (runs every hour)
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type SupabaseProfile struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio *string `json:"bio"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
IsOfficial bool `json:"is_official"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
BeaconEnabled bool `json:"beacon_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type SupabasePost struct {
|
||||
ID string `json:"id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Body string `json:"body"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CategoryID *string `json:"category_id"`
|
||||
Status string `json:"status"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
sbURL := os.Getenv("SUPABASE_URL")
|
||||
sbKey := os.Getenv("SUPABASE_KEY")
|
||||
|
||||
if dbURL == "" || sbURL == "" || sbKey == "" {
|
||||
log.Fatal("Missing env vars: DATABASE_URL, SUPABASE_URL, or SUPABASE_KEY")
|
||||
}
|
||||
|
||||
// Connect to Local DB
|
||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Fetch Profiles
|
||||
log.Println("Fetching profiles from Supabase...")
|
||||
var profiles []SupabaseProfile
|
||||
if err := fetchSupabase(sbURL, sbKey, "profiles", &profiles); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Found %d profiles", len(profiles))
|
||||
|
||||
// 2. Insert Profiles (and Users if needed)
|
||||
for _, p := range profiles {
|
||||
// Ensure User Exists
|
||||
var exists bool
|
||||
pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", p.ID).Scan(&exists)
|
||||
if !exists {
|
||||
// Create placeholder user
|
||||
placeholderEmail := fmt.Sprintf("imported_%s@sojorn.com", p.ID[:8])
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO users (id, email, encrypted_password, created_at)
|
||||
VALUES ($1, $2, 'placeholder_hash', $3)
|
||||
`, p.ID, placeholderEmail, p.CreatedAt)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create user for profile %s: %v", p.Handle, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert Profile
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO profiles (id, handle, display_name, bio, avatar_url, is_official, is_private, beacon_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
handle = EXCLUDED.handle,
|
||||
display_name = EXCLUDED.display_name,
|
||||
bio = EXCLUDED.bio,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
is_private = EXCLUDED.is_private
|
||||
`, p.ID, p.Handle, p.DisplayName, p.Bio, p.AvatarURL, p.IsOfficial, p.IsPrivate, p.BeaconEnabled, p.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to import profile %s: %v", p.Handle, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch Posts
|
||||
log.Println("Fetching posts from Supabase...")
|
||||
var posts []SupabasePost
|
||||
if err := fetchSupabase(sbURL, sbKey, "posts", &posts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Found %d posts", len(posts))
|
||||
|
||||
// 4. Insert Posts
|
||||
for _, p := range posts {
|
||||
// Default values if missing
|
||||
status := "active"
|
||||
if p.Status != "" {
|
||||
status = p.Status
|
||||
}
|
||||
visibility := "public"
|
||||
if p.Visibility != "" {
|
||||
visibility = p.Visibility
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO posts (id, author_id, body, image_url, category_id, status, visibility, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, p.ID, p.AuthorID, p.Body, p.ImageURL, p.CategoryID, status, visibility, p.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to import post %s: %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Migration complete.")
|
||||
}
|
||||
|
||||
func fetchSupabase(url, key, table string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/rest/v1/%s?select=*", url, table), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("apikey", key)
|
||||
req.Header.Add("Authorization", "Bearer "+key)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("supabase API error (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
|
@ -1,235 +1,255 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type FollowHandler struct {
|
||||
db interface {
|
||||
Exec(query string, args ...interface{}) (interface{}, error)
|
||||
Query(query string, args ...interface{}) (interface{}, error)
|
||||
QueryRow(query string, args ...interface{}) interface{}
|
||||
}
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFollowHandler(db interface {
|
||||
Exec(query string, args ...interface{}) (interface{}, error)
|
||||
Query(query string, args ...interface{}) (interface{}, error)
|
||||
QueryRow(query string, args ...interface{}) interface{}
|
||||
}) *FollowHandler {
|
||||
func NewFollowHandler(db *pgxpool.Pool) *FollowHandler {
|
||||
return &FollowHandler{db: db}
|
||||
}
|
||||
|
||||
// FollowUser creates a follow relationship
|
||||
// FollowUser — POST /users/:userId/follow
|
||||
func (h *FollowHandler) FollowUser(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := c.Param("userId")
|
||||
if targetUserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if userID == targetUserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow yourself"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot follow yourself"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
_, err := h.db.Exec(context.Background(), `
|
||||
INSERT INTO follows (follower_id, following_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (follower_id, following_id) DO NOTHING
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var followID string
|
||||
err := h.db.QueryRow(query, userID, targetUserID)
|
||||
`, userID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow user"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to follow user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Successfully followed user",
|
||||
"follow_id": followID,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
||||
}
|
||||
|
||||
// UnfollowUser removes a follow relationship
|
||||
// UnfollowUser — POST /users/:userId/unfollow
|
||||
func (h *FollowHandler) UnfollowUser(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := c.Param("userId")
|
||||
if targetUserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
DELETE FROM follows
|
||||
WHERE follower_id = $1 AND following_id = $2
|
||||
`
|
||||
|
||||
_, err := h.db.Exec(query, userID, targetUserID)
|
||||
_, err := h.db.Exec(context.Background(), `
|
||||
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2
|
||||
`, userID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow user"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unfollow user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Successfully unfollowed user"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
||||
}
|
||||
|
||||
// IsFollowing checks if current user follows target user
|
||||
// IsFollowing — GET /users/:userId/is-following
|
||||
func (h *FollowHandler) IsFollowing(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := c.Param("userId")
|
||||
if targetUserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM follows
|
||||
WHERE follower_id = $1 AND following_id = $2
|
||||
)
|
||||
`
|
||||
|
||||
var isFollowing bool
|
||||
err := h.db.QueryRow(query, userID, targetUserID)
|
||||
err := h.db.QueryRow(context.Background(), `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2
|
||||
)
|
||||
`, userID, targetUserID).Scan(&isFollowing)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check follow status"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check follow status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"is_following": isFollowing})
|
||||
}
|
||||
|
||||
// GetMutualFollowers returns users that both current user and target user follow
|
||||
// GetMutualFollowers — GET /users/:userId/mutual-followers
|
||||
func (h *FollowHandler) GetMutualFollowers(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := c.Param("userId")
|
||||
if targetUserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"})
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `SELECT * FROM get_mutual_followers($1, $2)`
|
||||
|
||||
rows, err := h.db.Query(query, userID, targetUserID)
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url
|
||||
FROM profiles p
|
||||
WHERE p.id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $1
|
||||
)
|
||||
AND p.id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $2
|
||||
)
|
||||
LIMIT 50
|
||||
`, userID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get mutual followers"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mutual followers"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var mutualFollowers []map[string]interface{}
|
||||
// Parse rows into mutualFollowers slice
|
||||
// Implementation depends on your DB driver
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"mutual_followers": mutualFollowers})
|
||||
type mutualUser struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
users := []mutualUser{}
|
||||
for rows.Next() {
|
||||
var u mutualUser
|
||||
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL); err == nil {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSuggestedUsers returns suggested users to follow
|
||||
c.JSON(http.StatusOK, gin.H{"mutual_followers": users})
|
||||
}
|
||||
|
||||
// GetSuggestedUsers — GET /users/suggested
|
||||
func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if limitParam := c.Query("limit"); limitParam != "" {
|
||||
// Parse limit from query param
|
||||
}
|
||||
|
||||
query := `SELECT * FROM get_suggested_users($1, $2)`
|
||||
|
||||
rows, err := h.db.Query(query, userID, limit)
|
||||
// Suggest users followed by people the current user follows, excluding already-followed
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT DISTINCT p.id, p.handle, p.display_name, p.avatar_url,
|
||||
COUNT(f2.follower_id) AS mutual_count
|
||||
FROM follows f1
|
||||
JOIN follows f2 ON f2.follower_id = f1.following_id
|
||||
JOIN profiles p ON p.id = f2.following_id
|
||||
WHERE f1.follower_id = $1
|
||||
AND f2.following_id != $1
|
||||
AND f2.following_id NOT IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $1
|
||||
)
|
||||
GROUP BY p.id, p.handle, p.display_name, p.avatar_url
|
||||
ORDER BY mutual_count DESC
|
||||
LIMIT 10
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get suggestions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var suggestions []map[string]interface{}
|
||||
// Parse rows into suggestions slice
|
||||
type suggestedUser struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
MutualCount int `json:"mutual_count"`
|
||||
}
|
||||
suggestions := []suggestedUser{}
|
||||
for rows.Next() {
|
||||
var u suggestedUser
|
||||
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.MutualCount); err == nil {
|
||||
suggestions = append(suggestions, u)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
|
||||
}
|
||||
|
||||
// GetFollowers returns list of users following the target user
|
||||
// GetFollowers — GET /users/:userId/followers
|
||||
func (h *FollowHandler) GetFollowers(c *gin.Context) {
|
||||
targetUserID := c.Param("userId")
|
||||
if targetUserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
||||
FROM follows f
|
||||
JOIN profiles p ON f.follower_id = p.user_id
|
||||
JOIN profiles p ON f.follower_id = p.id
|
||||
WHERE f.following_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
rows, err := h.db.Query(query, targetUserID)
|
||||
`, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get followers"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get followers"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var followers []map[string]interface{}
|
||||
// Parse rows
|
||||
type follower struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
FollowedAt string `json:"followed_at"`
|
||||
}
|
||||
followers := []follower{}
|
||||
for rows.Next() {
|
||||
var f follower
|
||||
var followedAt interface{}
|
||||
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
|
||||
followers = append(followers, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"followers": followers})
|
||||
}
|
||||
|
||||
// GetFollowing returns list of users that target user follows
|
||||
// GetFollowing — GET /users/:userId/following
|
||||
func (h *FollowHandler) GetFollowing(c *gin.Context) {
|
||||
targetUserID := c.Param("userId")
|
||||
if targetUserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
||||
FROM follows f
|
||||
JOIN profiles p ON f.following_id = p.user_id
|
||||
JOIN profiles p ON f.following_id = p.id
|
||||
WHERE f.follower_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
rows, err := h.db.Query(query, targetUserID)
|
||||
`, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get following"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get following"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var following []map[string]interface{}
|
||||
// Parse rows
|
||||
type followingUser struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
FollowedAt string `json:"followed_at"`
|
||||
}
|
||||
following := []followingUser{}
|
||||
for rows.Next() {
|
||||
var f followingUser
|
||||
var followedAt interface{}
|
||||
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
|
||||
following = append(following, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"following": following})
|
||||
}
|
||||
|
|
|
|||
111
go-backend/internal/handlers/profile_layout_handler.go
Normal file
111
go-backend/internal/handlers/profile_layout_handler.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type ProfileLayoutHandler struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewProfileLayoutHandler(db *pgxpool.Pool) *ProfileLayoutHandler {
|
||||
return &ProfileLayoutHandler{db: db}
|
||||
}
|
||||
|
||||
// GetProfileLayout — GET /profile/layout
|
||||
func (h *ProfileLayoutHandler) GetProfileLayout(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var widgetsJSON []byte
|
||||
var theme string
|
||||
var accentColor, bannerImageURL *string
|
||||
var updatedAt time.Time
|
||||
|
||||
err := h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT widgets, theme, accent_color, banner_image_url, updated_at
|
||||
FROM profile_layouts
|
||||
WHERE user_id = $1
|
||||
`, userIDStr).Scan(&widgetsJSON, &theme, &accentColor, &bannerImageURL, &updatedAt)
|
||||
|
||||
if err != nil {
|
||||
// No layout yet — return empty default
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"widgets": []interface{}{},
|
||||
"theme": "default",
|
||||
"accent_color": nil,
|
||||
"banner_image_url": nil,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var widgets interface{}
|
||||
if err := json.Unmarshal(widgetsJSON, &widgets); err != nil {
|
||||
widgets = []interface{}{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"widgets": widgets,
|
||||
"theme": theme,
|
||||
"accent_color": accentColor,
|
||||
"banner_image_url": bannerImageURL,
|
||||
"updated_at": updatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveProfileLayout — PUT /profile/layout
|
||||
func (h *ProfileLayoutHandler) SaveProfileLayout(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var req struct {
|
||||
Widgets interface{} `json:"widgets"`
|
||||
Theme string `json:"theme"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
BannerImageURL *string `json:"banner_image_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Theme == "" {
|
||||
req.Theme = "default"
|
||||
}
|
||||
|
||||
widgetsJSON, err := json.Marshal(req.Widgets)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid widgets format"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO profile_layouts (user_id, widgets, theme, accent_color, banner_image_url, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
widgets = EXCLUDED.widgets,
|
||||
theme = EXCLUDED.theme,
|
||||
accent_color = EXCLUDED.accent_color,
|
||||
banner_image_url = EXCLUDED.banner_image_url,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, userIDStr, widgetsJSON, req.Theme, req.AccentColor, req.BannerImageURL, now)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save layout"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"widgets": req.Widgets,
|
||||
"theme": req.Theme,
|
||||
"accent_color": req.AccentColor,
|
||||
"banner_image_url": req.BannerImageURL,
|
||||
"updated_at": now.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
502
go-backend/internal/handlers/repost_handler.go
Normal file
502
go-backend/internal/handlers/repost_handler.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type RepostHandler struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepostHandler(db *pgxpool.Pool) *RepostHandler {
|
||||
return &RepostHandler{db: db}
|
||||
}
|
||||
|
||||
// CreateRepost — POST /posts/repost
|
||||
func (h *RepostHandler) CreateRepost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var req struct {
|
||||
OriginalPostID string `json:"original_post_id" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Comment string `json:"comment"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{"standard": true, "quote": true, "boost": true, "amplify": true}
|
||||
if !validTypes[req.Type] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repost type"})
|
||||
return
|
||||
}
|
||||
|
||||
var authorHandle string
|
||||
var avatarURL *string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT handle, avatar_url FROM profiles WHERE id = $1", userIDStr,
|
||||
).Scan(&authorHandle, &avatarURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user info"})
|
||||
return
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO reposts (id, original_post_id, author_id, type, comment, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
|
||||
`, id, req.OriginalPostID, userIDStr, req.Type, req.Comment, req.Metadata, now)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create repost"})
|
||||
return
|
||||
}
|
||||
|
||||
countCol := repostCountColumn(req.Type)
|
||||
h.db.Exec(c.Request.Context(),
|
||||
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
|
||||
req.OriginalPostID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"repost": gin.H{
|
||||
"id": id,
|
||||
"original_post_id": req.OriginalPostID,
|
||||
"author_id": userIDStr,
|
||||
"author_handle": authorHandle,
|
||||
"author_avatar": avatarURL,
|
||||
"type": req.Type,
|
||||
"comment": req.Comment,
|
||||
"created_at": now.Format(time.RFC3339),
|
||||
"boost_count": 0,
|
||||
"amplification_score": 0,
|
||||
"is_amplified": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// BoostPost — POST /posts/boost
|
||||
func (h *RepostHandler) BoostPost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var req struct {
|
||||
PostID string `json:"post_id" binding:"required"`
|
||||
BoostType string `json:"boost_type" binding:"required"`
|
||||
BoostAmount int `json:"boost_amount"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.BoostAmount <= 0 {
|
||||
req.BoostAmount = 1
|
||||
}
|
||||
|
||||
maxDaily := 5
|
||||
if req.BoostType == "amplify" {
|
||||
maxDaily = 3
|
||||
}
|
||||
var dailyCount int
|
||||
h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT COUNT(*) FROM reposts
|
||||
WHERE author_id = $1 AND type = $2 AND created_at > NOW() - INTERVAL '24 hours'
|
||||
`, userIDStr, req.BoostType).Scan(&dailyCount)
|
||||
if dailyCount >= maxDaily {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "daily boost limit reached", "success": false})
|
||||
return
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
_, err := h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO reposts (id, original_post_id, author_id, type, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
|
||||
`, id, req.PostID, userIDStr, req.BoostType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to boost post"})
|
||||
return
|
||||
}
|
||||
|
||||
countCol := repostCountColumn(req.BoostType)
|
||||
h.db.Exec(c.Request.Context(),
|
||||
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
|
||||
req.PostID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetRepostsForPost — GET /posts/:id/reposts
|
||||
func (h *RepostHandler) GetRepostsForPost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT r.id, r.original_post_id, r.author_id,
|
||||
p.handle, p.avatar_url,
|
||||
r.type, r.comment, r.created_at
|
||||
FROM reposts r
|
||||
JOIN profiles p ON p.id = r.author_id
|
||||
WHERE r.original_post_id = $1
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $2
|
||||
`, postID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get reposts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reposts := buildRepostList(rows)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
|
||||
}
|
||||
|
||||
// GetUserReposts — GET /users/:id/reposts
|
||||
func (h *RepostHandler) GetUserReposts(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT r.id, r.original_post_id, r.author_id,
|
||||
p.handle, p.avatar_url,
|
||||
r.type, r.comment, r.created_at
|
||||
FROM reposts r
|
||||
JOIN profiles p ON p.id = r.author_id
|
||||
WHERE r.author_id = $1
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user reposts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reposts := buildRepostList(rows)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
|
||||
}
|
||||
|
||||
// DeleteRepost — DELETE /reposts/:id
|
||||
func (h *RepostHandler) DeleteRepost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
repostID := c.Param("id")
|
||||
|
||||
var origPostID, repostType string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT original_post_id, type FROM reposts WHERE id = $1 AND author_id = $2",
|
||||
repostID, userID.(string),
|
||||
).Scan(&origPostID, &repostType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repost not found"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.Exec(c.Request.Context(),
|
||||
"DELETE FROM reposts WHERE id = $1 AND author_id = $2",
|
||||
repostID, userID.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete repost"})
|
||||
return
|
||||
}
|
||||
|
||||
countCol := repostCountColumn(repostType)
|
||||
h.db.Exec(c.Request.Context(),
|
||||
"UPDATE posts SET "+countCol+" = GREATEST("+countCol+" - 1, 0) WHERE id = $1",
|
||||
origPostID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetAmplificationAnalytics — GET /posts/:id/amplification
|
||||
func (h *RepostHandler) GetAmplificationAnalytics(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
|
||||
var totalAmplification int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT COUNT(*) FROM reposts WHERE original_post_id = $1", postID,
|
||||
).Scan(&totalAmplification)
|
||||
|
||||
var viewCount int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT COALESCE(view_count, 1) FROM posts WHERE id = $1", postID,
|
||||
).Scan(&viewCount)
|
||||
if viewCount == 0 {
|
||||
viewCount = 1
|
||||
}
|
||||
amplificationRate := float64(totalAmplification) / float64(viewCount)
|
||||
|
||||
rows, _ := h.db.Query(c.Request.Context(),
|
||||
"SELECT type, COUNT(*) FROM reposts WHERE original_post_id = $1 GROUP BY type", postID)
|
||||
repostCounts := map[string]int{}
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var cnt int
|
||||
rows.Scan(&t, &cnt)
|
||||
repostCounts[t] = cnt
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"analytics": gin.H{
|
||||
"post_id": postID,
|
||||
"metrics": []gin.H{},
|
||||
"reposts": []gin.H{},
|
||||
"total_amplification": totalAmplification,
|
||||
"amplification_rate": amplificationRate,
|
||||
"repost_counts": repostCounts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetTrendingPosts — GET /posts/trending
|
||||
func (h *RepostHandler) GetTrendingPosts(c *gin.Context) {
|
||||
limit := clampInt(queryInt(c, "limit", 10), 1, 50)
|
||||
category := c.Query("category")
|
||||
|
||||
query := `
|
||||
SELECT p.id
|
||||
FROM posts p
|
||||
WHERE p.status = 'active'
|
||||
AND p.deleted_at IS NULL
|
||||
`
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if category != "" {
|
||||
query += " AND p.category = $" + strconv.Itoa(argIdx)
|
||||
args = append(args, category)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY (
|
||||
COALESCE(p.like_count, 0) * 1 +
|
||||
COALESCE(p.comment_count, 0) * 3 +
|
||||
COALESCE(p.repost_count, 0) * 4 +
|
||||
COALESCE(p.boost_count, 0) * 8 +
|
||||
COALESCE(p.amplify_count, 0) * 10
|
||||
) DESC, p.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argIdx)
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get trending posts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var postIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
rows.Scan(&id)
|
||||
postIDs = append(postIDs, id)
|
||||
}
|
||||
if postIDs == nil {
|
||||
postIDs = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "posts": postIDs})
|
||||
}
|
||||
|
||||
// GetAmplificationRules — GET /amplification/rules
|
||||
func (h *RepostHandler) GetAmplificationRules(c *gin.Context) {
|
||||
rules := []gin.H{
|
||||
{
|
||||
"id": "rule-standard", "name": "Standard Repost",
|
||||
"description": "Share a post with your followers",
|
||||
"type": "standard", "weight_multiplier": 1.0,
|
||||
"min_boost_score": 0, "max_daily_boosts": 20,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rule-quote", "name": "Quote Repost",
|
||||
"description": "Share a post with your commentary",
|
||||
"type": "quote", "weight_multiplier": 1.5,
|
||||
"min_boost_score": 0, "max_daily_boosts": 10,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rule-boost", "name": "Boost",
|
||||
"description": "Amplify a post's reach in the feed",
|
||||
"type": "boost", "weight_multiplier": 8.0,
|
||||
"min_boost_score": 0, "max_daily_boosts": 5,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rule-amplify", "name": "Amplify",
|
||||
"description": "Maximum amplification for high-quality content",
|
||||
"type": "amplify", "weight_multiplier": 10.0,
|
||||
"min_boost_score": 100, "max_daily_boosts": 3,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// CalculateAmplificationScore — POST /posts/:id/calculate-score
|
||||
func (h *RepostHandler) CalculateAmplificationScore(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
|
||||
var likes, comments, reposts, boosts, amplifies int
|
||||
h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT COALESCE(like_count,0), COALESCE(comment_count,0),
|
||||
COALESCE(repost_count,0), COALESCE(boost_count,0), COALESCE(amplify_count,0)
|
||||
FROM posts WHERE id = $1
|
||||
`, postID).Scan(&likes, &comments, &reposts, &boosts, &lifies)
|
||||
|
||||
score := likes*1 + comments*3 + reposts*4 + boosts*8 + amplifies*10
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "score": score})
|
||||
}
|
||||
|
||||
// CanBoostPost — GET /users/:id/can-boost/:postId
|
||||
func (h *RepostHandler) CanBoostPost(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
postID := c.Param("postId")
|
||||
boostType := c.Query("type")
|
||||
|
||||
var alreadyBoosted int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT COUNT(*) FROM reposts WHERE author_id=$1 AND original_post_id=$2 AND type=$3",
|
||||
userID, postID, boostType,
|
||||
).Scan(&alreadyBoosted)
|
||||
if alreadyBoosted > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"can_boost": false, "reason": "already_boosted"})
|
||||
return
|
||||
}
|
||||
|
||||
maxDaily := 5
|
||||
if boostType == "amplify" {
|
||||
maxDaily = 3
|
||||
}
|
||||
var dailyCount int
|
||||
h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT COUNT(*) FROM reposts
|
||||
WHERE author_id=$1 AND type=$2 AND created_at > NOW() - INTERVAL '24 hours'
|
||||
`, userID, boostType).Scan(&dailyCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"can_boost": dailyCount < maxDaily})
|
||||
}
|
||||
|
||||
// GetDailyBoostCount — GET /users/:id/daily-boosts
|
||||
func (h *RepostHandler) GetDailyBoostCount(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT type, COUNT(*) FROM reposts
|
||||
WHERE author_id=$1 AND created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY type
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get boost counts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
boostCounts := map[string]int{}
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var cnt int
|
||||
rows.Scan(&t, &cnt)
|
||||
boostCounts[t] = cnt
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "boost_counts": boostCounts})
|
||||
}
|
||||
|
||||
// ReportRepost — POST /reposts/:id/report
|
||||
func (h *RepostHandler) ReportRepost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
repostID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO repost_reports (id, repost_id, reporter_id, reason, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (repost_id, reporter_id) DO NOTHING
|
||||
`, uuid.New().String(), repostID, userID.(string), req.Reason)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to report repost"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func repostCountColumn(repostType string) string {
|
||||
switch repostType {
|
||||
case "boost":
|
||||
return "boost_count"
|
||||
case "amplify":
|
||||
return "amplify_count"
|
||||
default:
|
||||
return "repost_count"
|
||||
}
|
||||
}
|
||||
|
||||
func queryInt(c *gin.Context, key string, def int) int {
|
||||
if s := c.Query(key); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func clampInt(v, min, max int) int {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func buildRepostList(rows interface {
|
||||
Next() bool
|
||||
Scan(...interface{}) error
|
||||
Close()
|
||||
}) []gin.H {
|
||||
list := []gin.H{}
|
||||
for rows.Next() {
|
||||
var id, origPostID, authorID, handle, repostType string
|
||||
var avatarURL, comment *string
|
||||
var createdAt time.Time
|
||||
rows.Scan(&id, &origPostID, &authorID, &handle, &avatarURL, &repostType, &comment, &createdAt)
|
||||
list = append(list, gin.H{
|
||||
"id": id,
|
||||
"original_post_id": origPostID,
|
||||
"author_id": authorID,
|
||||
"author_handle": handle,
|
||||
"author_avatar": avatarURL,
|
||||
"type": repostType,
|
||||
"comment": comment,
|
||||
"created_at": createdAt.Format(time.RFC3339),
|
||||
"boost_count": 0,
|
||||
"amplification_score": 0,
|
||||
"is_amplified": false,
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ import (
|
|||
|
||||
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the algorithm (Supabase uses HS256 usually)
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
|
@ -31,7 +30,6 @@ func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, er
|
|||
return "", nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// Supabase uses 'sub' field for user ID
|
||||
userID, ok := claims["sub"].(string)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("token missing user ID")
|
||||
|
|
|
|||
75
go-backend/migrations/20260217_repost_and_layout.sql
Normal file
75
go-backend/migrations/20260217_repost_and_layout.sql
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
-- Migration: Add reposts, profile_layouts, and post_feed_scores tables
|
||||
-- Also adds engagement count columns to posts for feed algorithm
|
||||
|
||||
-- ─── Engagement columns on posts ──────────────────────────────────────────────
|
||||
ALTER TABLE public.posts
|
||||
ADD COLUMN IF NOT EXISTS like_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS share_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS repost_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS boost_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS amplify_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS video_url TEXT;
|
||||
|
||||
-- Backfill existing like/comment/view counts from post_metrics
|
||||
UPDATE public.posts p
|
||||
SET
|
||||
like_count = COALESCE(m.like_count, 0),
|
||||
comment_count = COALESCE(m.comment_count, 0),
|
||||
view_count = COALESCE(m.view_count, 0)
|
||||
FROM public.post_metrics m
|
||||
WHERE p.id = m.post_id;
|
||||
|
||||
-- ─── Reposts ──────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.reposts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
original_post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||
author_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('standard', 'quote', 'boost', 'amplify')),
|
||||
comment TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One repost per type per user per post
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reposts_unique
|
||||
ON public.reposts (original_post_id, author_id, type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reposts_original_post_id ON public.reposts (original_post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reposts_author_id ON public.reposts (author_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reposts_created_at ON public.reposts (created_at DESC);
|
||||
|
||||
-- ─── Repost reports ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.repost_reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
repost_id UUID NOT NULL REFERENCES public.reposts(id) ON DELETE CASCADE,
|
||||
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (repost_id, reporter_id)
|
||||
);
|
||||
|
||||
-- ─── Profile widget layouts ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.profile_layouts (
|
||||
user_id UUID PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
widgets JSONB NOT NULL DEFAULT '[]',
|
||||
theme VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
accent_color VARCHAR(20),
|
||||
banner_image_url TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─── Post feed scores (feed algorithm) ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.post_feed_scores (
|
||||
post_id UUID PRIMARY KEY REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||
score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
engagement_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
quality_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
recency_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
network_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
personalization DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_feed_scores_score ON public.post_feed_scores (score DESC);
|
||||
|
|
@ -833,7 +833,7 @@ class _ChatDataManagementScreenState extends State<ChatDataManagementScreen> {
|
|||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
await _e2ee.forceResetBrokenKeys();
|
||||
await _e2ee.resetIdentityKeys();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
|
||||
|
|
|
|||
|
|
@ -1050,56 +1050,6 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
|||
_chatService.markMessageLocallyDeleted(messageId);
|
||||
}
|
||||
|
||||
Future<void> _forceResetBrokenKeys() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Force Reset All Keys?'),
|
||||
content: const Text(
|
||||
'This will DELETE all encryption keys and generate fresh 256-bit keys. '
|
||||
'This fixes the 208-bit key bug that causes MAC errors. '
|
||||
'All existing messages will become undecryptable.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
|
||||
child: const Text('Force Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await _chatService.forceResetBrokenKeys();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keys force reset! Restart chat to test.'),
|
||||
backgroundColor: SojornColors.destructive,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error resetting keys: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Widget _buildInputArea() {
|
||||
return ComposerWidget(
|
||||
controller: _messageController,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class _EncryptionHubScreenState extends State<EncryptionHubScreen> {
|
|||
);
|
||||
if (confirmed != true) return;
|
||||
final e2ee = SimpleE2EEService();
|
||||
await e2ee.forceResetBrokenKeys();
|
||||
await e2ee.resetIdentityKeys();
|
||||
await _loadStatus();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class RepostService {
|
|||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/api/posts/repost', {
|
||||
final response = await ApiService.instance.post('/posts/repost', {
|
||||
'original_post_id': originalPostId,
|
||||
'type': type.name,
|
||||
'comment': comment,
|
||||
|
|
@ -41,7 +41,7 @@ class RepostService {
|
|||
int? boostAmount,
|
||||
}) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/api/posts/boost', {
|
||||
final response = await ApiService.instance.post('/posts/boost', {
|
||||
'post_id': postId,
|
||||
'boost_type': boostType.name,
|
||||
'boost_amount': boostAmount ?? 1,
|
||||
|
|
@ -57,7 +57,7 @@ class RepostService {
|
|||
/// Get all reposts for a post
|
||||
static Future<List<Repost>> getRepostsForPost(String postId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/posts/$postId/reposts');
|
||||
final response = await ApiService.instance.get('/posts/$postId/reposts');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
||||
|
|
@ -72,7 +72,7 @@ class RepostService {
|
|||
/// Get user's repost history
|
||||
static Future<List<Repost>> getUserReposts(String userId, {int limit = 20}) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/users/$userId/reposts?limit=$limit');
|
||||
final response = await ApiService.instance.get('/users/$userId/reposts?limit=$limit');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
||||
|
|
@ -87,7 +87,7 @@ class RepostService {
|
|||
/// Delete a repost
|
||||
static Future<bool> deleteRepost(String repostId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.delete('/api/reposts/$repostId');
|
||||
final response = await ApiService.instance.delete('/reposts/$repostId');
|
||||
return response['success'] == true;
|
||||
} catch (e) {
|
||||
print('Error deleting repost: $e');
|
||||
|
|
@ -98,7 +98,7 @@ class RepostService {
|
|||
/// Get amplification analytics for a post
|
||||
static Future<AmplificationAnalytics?> getAmplificationAnalytics(String postId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/posts/$postId/amplification');
|
||||
final response = await ApiService.instance.get('/posts/$postId/amplification');
|
||||
|
||||
if (response['success'] == true) {
|
||||
return AmplificationAnalytics.fromJson(response['analytics']);
|
||||
|
|
@ -112,7 +112,7 @@ class RepostService {
|
|||
/// Get trending posts based on amplification
|
||||
static Future<List<Post>> getTrendingPosts({int limit = 10, String? category}) async {
|
||||
try {
|
||||
String url = '/api/posts/trending?limit=$limit';
|
||||
String url = '/posts/trending?limit=$limit';
|
||||
if (category != null) {
|
||||
url += '&category=$category';
|
||||
}
|
||||
|
|
@ -132,7 +132,7 @@ class RepostService {
|
|||
/// Get amplification rules
|
||||
static Future<List<FeedAmplificationRule>> getAmplificationRules() async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/amplification/rules');
|
||||
final response = await ApiService.instance.get('/amplification/rules');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final rulesData = response['rules'] as List<dynamic>? ?? [];
|
||||
|
|
@ -147,7 +147,7 @@ class RepostService {
|
|||
/// Calculate amplification score for a post
|
||||
static Future<int> calculateAmplificationScore(String postId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/api/posts/$postId/calculate-score', {});
|
||||
final response = await ApiService.instance.post('/posts/$postId/calculate-score', {});
|
||||
|
||||
if (response['success'] == true) {
|
||||
return response['score'] as int? ?? 0;
|
||||
|
|
@ -161,7 +161,7 @@ class RepostService {
|
|||
/// Check if user can boost a post
|
||||
static Future<bool> canBoostPost(String userId, String postId, RepostType boostType) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/users/$userId/can-boost/$postId?type=${boostType.name}');
|
||||
final response = await ApiService.instance.get('/users/$userId/can-boost/$postId?type=${boostType.name}');
|
||||
|
||||
return response['can_boost'] == true;
|
||||
} catch (e) {
|
||||
|
|
@ -173,7 +173,7 @@ class RepostService {
|
|||
/// Get user's daily boost count
|
||||
static Future<Map<RepostType, int>> getDailyBoostCount(String userId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/users/$userId/daily-boosts');
|
||||
final response = await ApiService.instance.get('/users/$userId/daily-boosts');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final boostCounts = response['boost_counts'] as Map<String, dynamic>? ?? {};
|
||||
|
|
@ -195,7 +195,7 @@ class RepostService {
|
|||
/// Report inappropriate repost
|
||||
static Future<bool> reportRepost(String repostId, String reason) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/api/reposts/$repostId/report', {
|
||||
final response = await ApiService.instance.post('/reposts/$repostId/report', {
|
||||
'reason': reason,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -81,9 +81,8 @@ class SecureChatService {
|
|||
}
|
||||
}
|
||||
|
||||
// Force reset to fix 208-bit key bug
|
||||
Future<void> forceResetBrokenKeys() async {
|
||||
await _e2ee.forceResetBrokenKeys();
|
||||
Future<void> resetIdentityKeys() async {
|
||||
await _e2ee.resetIdentityKeys();
|
||||
}
|
||||
|
||||
// Manual key upload for testing
|
||||
|
|
|
|||
|
|
@ -96,36 +96,18 @@ class SimpleE2EEService {
|
|||
|
||||
}
|
||||
|
||||
// Force reset to fix 208-bit key bug
|
||||
Future<void> forceResetBrokenKeys() async {
|
||||
|
||||
// Clear ALL storage completely
|
||||
// Reset all local encryption keys and generate a fresh identity.
|
||||
// Existing encrypted messages will become undecryptable after this.
|
||||
Future<void> resetIdentityKeys() async {
|
||||
await _storage.deleteAll();
|
||||
|
||||
// Clear local key variables
|
||||
_identityDhKeyPair = null;
|
||||
_identitySigningKeyPair = null;
|
||||
_signedPreKey = null;
|
||||
_oneTimePreKeys = null;
|
||||
_initializedForUserId = null;
|
||||
_initFuture = null;
|
||||
|
||||
// Clear session cache
|
||||
_sessionCache.clear();
|
||||
|
||||
|
||||
// Generate fresh identity with proper key lengths
|
||||
await generateNewIdentity();
|
||||
|
||||
// Verify the new keys are proper length
|
||||
if (_identityDhKeyPair != null) {
|
||||
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
||||
}
|
||||
|
||||
if (_identitySigningKeyPair != null) {
|
||||
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Manual key upload for testing
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/link_handler.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import '../screens/discover/discover_screen.dart';
|
||||
|
||||
/// Rich text widget that automatically detects and styles URLs and mentions.
|
||||
|
|
@ -107,11 +108,11 @@ class sojornRichText extends StatelessWidget {
|
|||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
if (isMention) {
|
||||
_navigateToProfile(matchText);
|
||||
_navigateToProfile(context, matchText);
|
||||
} else if (isHashtag) {
|
||||
_navigateToHashtag(matchText);
|
||||
_navigateToHashtag(context, matchText);
|
||||
} else {
|
||||
_navigateToUrl(matchText);
|
||||
_navigateToUrl(context, matchText);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -144,23 +145,22 @@ class sojornRichText extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
void _navigateToProfile(String username) {
|
||||
// Remove @ prefix if present
|
||||
void _navigateToProfile(BuildContext context, String username) {
|
||||
final cleanUsername = username.startsWith('@') ? username.substring(1) : username;
|
||||
// Navigate to profile screen
|
||||
// This would typically use GoRouter or Navigator
|
||||
print('Navigate to profile: $cleanUsername');
|
||||
AppRoutes.navigateToProfile(context, cleanUsername);
|
||||
}
|
||||
|
||||
void _navigateToHashtag(String hashtag) {
|
||||
// Remove # prefix if present
|
||||
void _navigateToHashtag(BuildContext context, String hashtag) {
|
||||
final cleanHashtag = hashtag.startsWith('#') ? hashtag.substring(1) : hashtag;
|
||||
// Navigate to search/discover with hashtag
|
||||
print('Navigate to hashtag: $cleanHashtag');
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DiscoverScreen(initialQuery: '#$cleanHashtag'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToUrl(String url) {
|
||||
// Launch URL in browser or handle in-app
|
||||
print('Navigate to URL: $url');
|
||||
void _navigateToUrl(BuildContext context, String url) {
|
||||
LinkHandler.launchLink(context, url);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue