**Major Features Added:** - **Inline Reply System**: Replace compose screen with inline reply boxes - **Thread Navigation**: Parent/child navigation with jump functionality - **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy - **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions **Frontend Changes:** - **ThreadedCommentWidget**: Complete rewrite with animations and navigation - **ThreadNode Model**: Added parent references and descendant counting - **ThreadedConversationScreen**: Integrated navigation handlers - **PostDetailScreen**: Replaced with threaded conversation view - **ComposeScreen**: Added reply indicators and context - **PostActions**: Fixed visibility checks for chain buttons **Backend Changes:** - **API Route**: Added /posts/:id/thread endpoint - **Post Repository**: Include allow_chain and visibility fields in feed - **Thread Handler**: Support for fetching post chains **UI/UX Improvements:** - **Reply Context**: Clear indication when replying to specific posts - **Character Counting**: 500 character limit with live counter - **Visual Hierarchy**: Depth-based indentation and styling - **Smooth Animations**: SizeTransition, FadeTransition, hover states - **Chain Navigation**: Parent/child buttons with visual feedback **Technical Enhancements:** - **Animation Controllers**: Proper lifecycle management - **State Management**: Clean separation of concerns - **Navigation Callbacks**: Reusable navigation system - **Error Handling**: Graceful fallbacks and user feedback This creates a Reddit-style threaded conversation experience with smooth animations, inline replies, and intuitive navigation between posts in a chain.
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/patbritton/sojorn-backend/internal/models"
|
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
|
"github.com/patbritton/sojorn-backend/internal/services"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type UserHandler struct {
|
|
repo *repository.UserRepository
|
|
postRepo *repository.PostRepository
|
|
pushService *services.PushService
|
|
assetService *services.AssetService
|
|
}
|
|
|
|
func NewUserHandler(repo *repository.UserRepository, postRepo *repository.PostRepository, pushService *services.PushService, assetService *services.AssetService) *UserHandler {
|
|
return &UserHandler{
|
|
repo: repo,
|
|
postRepo: postRepo,
|
|
pushService: pushService,
|
|
assetService: assetService,
|
|
}
|
|
}
|
|
|
|
func (h *UserHandler) GetProfile(c *gin.Context) {
|
|
userID := c.Param("id")
|
|
handle, handleExists := c.GetQuery("handle")
|
|
|
|
var profile *models.Profile
|
|
var err error
|
|
|
|
if userID != "" {
|
|
profile, err = h.repo.GetProfileByID(c.Request.Context(), userID)
|
|
} else if handleExists {
|
|
if handle == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Handle cannot be empty"})
|
|
return
|
|
}
|
|
profile, err = h.repo.GetProfileByHandle(c.Request.Context(), handle)
|
|
} else {
|
|
// Fallback to current authenticated user
|
|
if val, exists := c.Get("user_id"); exists {
|
|
userID = val.(string)
|
|
profile, err = h.repo.GetProfileByID(c.Request.Context(), userID)
|
|
}
|
|
}
|
|
|
|
if err != nil || profile == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
|
|
return
|
|
}
|
|
|
|
// Use the profile ID for subsequent lookups
|
|
actualUserID := profile.ID.String()
|
|
|
|
// Get stats
|
|
stats, _ := h.repo.GetProfileStats(c.Request.Context(), actualUserID)
|
|
|
|
// Check follow status if authenticated
|
|
isFollowing := false
|
|
isFollowedBy := false
|
|
followStatus := ""
|
|
if currentUserID, exists := c.Get("user_id"); exists && currentUserID.(string) != actualUserID {
|
|
var err error
|
|
isFollowing, err = h.repo.IsFollowing(c.Request.Context(), currentUserID.(string), actualUserID)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to check isFollowing")
|
|
}
|
|
isFollowedBy, err = h.repo.IsFollowing(c.Request.Context(), actualUserID, currentUserID.(string))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to check isFollowedBy")
|
|
}
|
|
followStatus, _ = h.repo.GetFollowStatus(c.Request.Context(), currentUserID.(string), actualUserID)
|
|
}
|
|
|
|
// Sign URLs
|
|
if profile.AvatarURL != nil {
|
|
signed := h.assetService.SignImageURL(*profile.AvatarURL)
|
|
profile.AvatarURL = &signed
|
|
}
|
|
if profile.CoverURL != nil {
|
|
signed := h.assetService.SignImageURL(*profile.CoverURL)
|
|
profile.CoverURL = &signed
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"profile": profile,
|
|
"stats": stats,
|
|
"is_following": isFollowing,
|
|
"is_followed_by": isFollowedBy,
|
|
"is_friend": isFollowing && isFollowedBy,
|
|
"follow_status": followStatus,
|
|
"is_private": profile.IsPrivate,
|
|
})
|
|
}
|
|
|
|
func (h *UserHandler) Follow(c *gin.Context) {
|
|
followerID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
followingID := c.Param("id")
|
|
if followingID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
return
|
|
}
|
|
|
|
status, err := h.repo.FollowUser(c.Request.Context(), followerID.(string), followingID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "cannot follow self") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow self"})
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "23503") || strings.Contains(err.Error(), "target profile not found") { // FK Violation or custom error
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User to follow not found"})
|
|
return
|
|
}
|
|
log.Error().Err(err).Msg("Failed to follow user")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow user", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Send Push Notification
|
|
go func(targetID string, actorID string, status string) {
|
|
message := "You have a new follower!"
|
|
msgType := "new_follower"
|
|
if status == "pending" {
|
|
message = "You have a new follow request!"
|
|
msgType = "follow_request"
|
|
}
|
|
|
|
err := h.pushService.SendPush(context.Background(), targetID, "New Follower", message, map[string]string{
|
|
"type": msgType,
|
|
"follower_id": actorID,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to send push notification")
|
|
}
|
|
}(followingID, followerID.(string), status)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Follow update successful", "status": status})
|
|
}
|
|
|
|
func (h *UserHandler) Unfollow(c *gin.Context) {
|
|
followerID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
followingID := c.Param("id")
|
|
if followingID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
return
|
|
}
|
|
|
|
if err := h.repo.UnfollowUser(c.Request.Context(), followerID.(string), followingID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow user"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Unfollowed successfully"})
|
|
}
|
|
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
|
|
var req struct {
|
|
Handle *string `json:"handle"`
|
|
DisplayName *string `json:"display_name"`
|
|
Bio *string `json:"bio"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
CoverURL *string `json:"cover_url"`
|
|
Location *string `json:"location"`
|
|
Website *string `json:"website"`
|
|
Interests []string `json:"interests"`
|
|
IdentityKey *string `json:"identity_key"`
|
|
RegistrationID *int `json:"registration_id"`
|
|
EncryptedPrivateKey *string `json:"encrypted_private_key"`
|
|
IsPrivate *bool `json:"is_private"`
|
|
IsOfficial *bool `json:"is_official"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
profile := &models.Profile{
|
|
ID: userID,
|
|
Handle: req.Handle,
|
|
DisplayName: req.DisplayName,
|
|
Bio: req.Bio,
|
|
AvatarURL: req.AvatarURL,
|
|
CoverURL: req.CoverURL,
|
|
Location: req.Location,
|
|
Website: req.Website,
|
|
Interests: req.Interests,
|
|
IdentityKey: req.IdentityKey,
|
|
RegistrationID: req.RegistrationID,
|
|
EncryptedPrivateKey: req.EncryptedPrivateKey,
|
|
IsPrivate: req.IsPrivate,
|
|
IsOfficial: req.IsOfficial,
|
|
}
|
|
|
|
err := h.repo.UpdateProfile(c.Request.Context(), profile)
|
|
if err != nil {
|
|
// Log error
|
|
log.Error().Err(err).Msg("Failed to update profile")
|
|
|
|
// Check for duplicate handle
|
|
if strings.Contains(err.Error(), "23505") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
updated, _ := h.repo.GetProfileByID(c.Request.Context(), userID.String())
|
|
c.JSON(http.StatusOK, gin.H{"profile": updated})
|
|
}
|
|
|
|
func (h *UserHandler) GetSavedPosts(c *gin.Context) {
|
|
userID := c.GetString("user_id") // Authenticated user
|
|
|
|
// Optional: allow viewing other's saved posts if public? For now assume "me" context from route usually,
|
|
// but if route is /users/:id/saved we would use param.
|
|
// The prompt asked for GET /users/me/saved, which implies authenticated user.
|
|
|
|
// Check if a specific ID is requested in URL, otherwise use authenticated user
|
|
// However, usually saved posts are private. Let's assume me-only for now or strictly follow ID if provided.
|
|
// The prompt says GET /api/v1/users/me/saved
|
|
|
|
// If the route is /users/me/saved, we rely on the middleware setting user_id.
|
|
|
|
limit := 20
|
|
offset := 0
|
|
// simplified for brevity, in real app parse query params
|
|
|
|
posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to fetch saved posts")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts"})
|
|
return
|
|
}
|
|
// Sign URLs
|
|
for i := range posts {
|
|
if posts[i].ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ImageURL)
|
|
posts[i].ImageURL = &signed
|
|
}
|
|
if posts[i].VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*posts[i].VideoURL)
|
|
posts[i].VideoURL = &signed
|
|
}
|
|
if posts[i].ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ThumbnailURL)
|
|
posts[i].ThumbnailURL = &signed
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *UserHandler) GetLikedPosts(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
limit := 20
|
|
offset := 0
|
|
|
|
posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to fetch liked posts")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts"})
|
|
return
|
|
}
|
|
// Sign URLs
|
|
for i := range posts {
|
|
if posts[i].ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ImageURL)
|
|
posts[i].ImageURL = &signed
|
|
}
|
|
if posts[i].VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*posts[i].VideoURL)
|
|
posts[i].VideoURL = &signed
|
|
}
|
|
if posts[i].ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ThumbnailURL)
|
|
posts[i].ThumbnailURL = &signed
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *UserHandler) AcceptFollowRequest(c *gin.Context) {
|
|
userIdStr, _ := c.Get("user_id")
|
|
requesterId := c.Param("id")
|
|
|
|
if err := h.repo.AcceptFollowRequest(c.Request.Context(), userIdStr.(string), requesterId); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to accept follow request"})
|
|
return
|
|
}
|
|
|
|
// Harmony & Notifications
|
|
go func(targetID, actorID string) {
|
|
// 1. Update Harmony Scores (Mutual gain)
|
|
_ = h.repo.UpdateHarmonyScore(context.Background(), targetID, 2)
|
|
_ = h.repo.UpdateHarmonyScore(context.Background(), actorID, 2)
|
|
|
|
// 2. Send Push Notification to requester
|
|
err := h.pushService.SendPush(context.Background(), actorID, "Request Accepted", "Your follow request was accepted!", map[string]string{
|
|
"type": "follow_accepted",
|
|
"follower_id": targetID,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to send follow acceptance push")
|
|
}
|
|
}(userIdStr.(string), requesterId)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Follow request accepted"})
|
|
}
|
|
|
|
func (h *UserHandler) GetPendingFollowRequests(c *gin.Context) {
|
|
userIdStr, _ := c.Get("user_id")
|
|
|
|
requests, err := h.repo.GetPendingFollowRequests(c.Request.Context(), userIdStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch pending follow requests"})
|
|
return
|
|
}
|
|
|
|
// Sign URLs for avatars in requests
|
|
for i := range requests {
|
|
if avatar, ok := requests[i]["avatar_url"].(string); ok && avatar != "" {
|
|
requests[i]["avatar_url"] = h.assetService.SignImageURL(avatar)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"requests": requests})
|
|
}
|
|
|
|
func (h *UserHandler) RejectFollowRequest(c *gin.Context) {
|
|
userIdStr, _ := c.Get("user_id")
|
|
requesterId := c.Param("id")
|
|
|
|
if err := h.repo.RejectFollowRequest(c.Request.Context(), userIdStr.(string), requesterId); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject follow request"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Follow request rejected"})
|
|
}
|