sojorn/go-backend/internal/handlers/chat_handler.go
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**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.
2026-01-30 07:40:19 -06:00

273 lines
8.5 KiB
Go

package handlers
import (
"context"
// "encoding/base64"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/patbritton/sojorn-backend/internal/models"
"github.com/patbritton/sojorn-backend/internal/realtime"
"github.com/patbritton/sojorn-backend/internal/repository"
"github.com/patbritton/sojorn-backend/internal/services"
"github.com/rs/zerolog/log"
)
type ChatHandler struct {
chatRepo *repository.ChatRepository
pushService *services.PushService
hub *realtime.Hub
}
func NewChatHandler(chatRepo *repository.ChatRepository, pushService *services.PushService, hub *realtime.Hub) *ChatHandler {
return &ChatHandler{
chatRepo: chatRepo,
pushService: pushService,
hub: hub,
}
}
func (h *ChatHandler) GetConversations(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
conversations, err := h.chatRepo.GetConversations(c.Request.Context(), userIDStr.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch conversations"})
return
}
c.JSON(http.StatusOK, gin.H{"conversations": conversations})
}
func (h *ChatHandler) GetOrCreateConversation(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
otherUserID := c.Query("other_user_id")
if otherUserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "other_user_id is required"})
return
}
id, err := h.chatRepo.GetOrCreateConversation(c.Request.Context(), userIDStr.(string), otherUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to handle conversation"})
return
}
c.JSON(http.StatusOK, gin.H{"conversation_id": id})
}
func (h *ChatHandler) SendMessage(c *gin.Context) {
senderIDStr, _ := c.Get("user_id")
senderID, err := uuid.Parse(senderIDStr.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid sender ID"})
return
}
var req struct {
ConversationID string `json:"conversation_id" binding:"required"`
ReceiverID string `json:"receiver_id"`
Ciphertext string `json:"ciphertext" binding:"required"`
IV string `json:"iv"`
KeyVersion string `json:"key_version"`
MessageHeader string `json:"message_header"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
convID, err := uuid.Parse(req.ConversationID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation ID"})
return
}
pA, pB, err := h.chatRepo.GetParticipants(c.Request.Context(), req.ConversationID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to authenticate conversation participants"})
return
}
if senderID.String() != pA && senderID.String() != pB {
c.JSON(http.StatusForbidden, gin.H{"error": "You are not a participant in this conversation"})
return
}
otherParticipant := pA
if senderID.String() == pA {
otherParticipant = pB
}
var receiverID uuid.UUID
if req.ReceiverID != "" {
receiverID, err = uuid.Parse(req.ReceiverID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid receiver ID"})
return
}
if receiverID.String() != otherParticipant {
c.JSON(http.StatusForbidden, gin.H{"error": "Receiver is not a participant in this conversation"})
return
}
} else {
receiverID, _ = uuid.Parse(otherParticipant)
}
// Persist blind ciphertext to DB
msg, err := h.chatRepo.CreateMessage(c.Request.Context(), senderID, receiverID, convID, req.Ciphertext, req.IV, req.KeyVersion, req.MessageHeader)
if err != nil {
log.Error().Err(err).Msg("Failed to persist secure message")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send message"})
return
}
// Prepare Real-time Payload
rtPayload := gin.H{
"type": "new_message",
"payload": gin.H{
"id": msg.ID,
"conversation_id": msg.ConversationID,
"sender_id": msg.SenderID,
"receiver_id": msg.ReceiverID,
"ciphertext": msg.Ciphertext,
"iv": msg.IV,
"key_version": msg.KeyVersion, // e.g. "x3dh"
"message_header": msg.MessageHeader,
"created_at": msg.CreatedAt,
},
}
// 1. Send via WebSocket (Best Effort, Immediate)
h.hub.SendToUser(receiverID.String(), rtPayload)
// 2. Send via Push Notification (Background, Reliable)
// We run this in a goroutine to not block the REST response time,
// but strictly AFTER DB persistence is confirmed.
go func(recipID string, m *models.EncryptedMessage) {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("user_id", recipID).Msg("Push notification panic recovered")
}
}()
err := h.pushService.SendPush(context.Background(), recipID, "New Message", "You have a new secure message", map[string]string{
"type": "new_message",
"conversation_id": m.ConversationID.String(),
"encrypted": "true",
})
if err != nil {
log.Warn().Err(err).Str("user_id", recipID).Msg("Failed to send push notification")
}
}(receiverID.String(), msg)
c.JSON(http.StatusCreated, msg)
}
func (h *ChatHandler) GetMessages(c *gin.Context) {
convIDStr := c.Param("id")
convID, err := uuid.Parse(convIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation ID"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
messages, err := h.chatRepo.GetMessages(c.Request.Context(), convID, limit, offset)
if err != nil {
log.Error().Err(err).Str("conversation_id", convIDStr).Msg("Failed to fetch messages")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
c.JSON(http.StatusOK, gin.H{"messages": messages})
}
func (h *ChatHandler) GetMutualFollows(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
profiles, err := h.chatRepo.GetMutualFollows(c.Request.Context(), userIDStr.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch mutual follows"})
return
}
c.JSON(http.StatusOK, gin.H{"profiles": profiles})
}
func (h *ChatHandler) DeleteConversation(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
convIDStr := c.Param("id")
convID, err := uuid.Parse(convIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation ID"})
return
}
err = h.chatRepo.DeleteConversation(c.Request.Context(), convID, userIDStr.(string))
if err != nil {
log.Error().Err(err).Str("conversation_id", convIDStr).Msg("Failed to delete conversation")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete conversation"})
return
}
// Broadcast deletion to current user via WebSocket
deleteEvent := map[string]interface{}{
"type": "conversation_deleted",
"payload": map[string]interface{}{
"conversation_id": convID.String(),
"deleted_by": userIDStr,
},
}
// Send to current user (all their devices)
_ = h.hub.SendToUser(userIDStr.(string), deleteEvent)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Conversation permanently deleted"})
}
func (h *ChatHandler) DeleteMessage(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
msgIDStr := c.Param("id")
msgID, err := uuid.Parse(msgIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message ID"})
return
}
// Get conversation and recipient info before deleting
var conversationID, senderID, receiverID uuid.UUID
err = h.chatRepo.GetMessageInfo(c.Request.Context(), msgID, &conversationID, &senderID, &receiverID)
if err != nil {
log.Error().Err(err).Str("message_id", msgIDStr).Msg("Failed to get message info")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get message info"})
return
}
err = h.chatRepo.DeleteMessage(c.Request.Context(), msgID, userIDStr.(string))
if err != nil {
log.Error().Err(err).Str("message_id", msgIDStr).Msg("Failed to delete message")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"})
return
}
// Broadcast deletion to both participants via WebSocket
deleteEvent := map[string]interface{}{
"type": "message_deleted",
"payload": map[string]interface{}{
"message_id": msgID.String(),
"conversation_id": conversationID.String(),
"deleted_by": userIDStr,
},
}
// Send to sender (all their devices)
_ = h.hub.SendToUser(senderID.String(), deleteEvent)
// Send to receiver (all their devices)
_ = h.hub.SendToUser(receiverID.String(), deleteEvent)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Message permanently deleted"})
}