diff --git a/README.md b/README.md index 38270b1..f63c576 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Every feature you see is something I personally needed and use. Your feedback, b ### Advanced Features - **AI-Powered Recommendations**: Intelligent content suggestions and organization +- **Integrated Messaging (V1)**: Discord-style conversations (self chat, DMs, groups, team channels, global channels), realtime updates, smart suggestions, deep-link references, encrypted password vault sharing, voice notes, and browser-local optional transcription/call signaling - **OAuth Integration**: Secure authentication with GitHub and other providers - **Mobile App**: Native React Native application for iOS and Android - **Email Ingestion**: Send/forward emails to automatically import content diff --git a/backend/go.mod b/backend/go.mod index 4543e50..bd7757d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,20 +3,23 @@ module github.com/trackeep/backend go 1.24.0 require ( + github.com/PuerkitoBio/goquery v1.11.0 github.com/chromedp/chromedp v0.9.3 github.com/gin-gonic/gin v1.9.1 + github.com/go-redis/redis/v8 v8.11.5 github.com/gocolly/colly/v2 v2.3.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/pquerna/otp v1.5.0 golang.org/x/crypto v0.47.0 + golang.org/x/net v0.48.0 golang.org/x/oauth2 v0.17.0 gorm.io/driver/postgres v1.5.4 gorm.io/gorm v1.25.5 ) require ( - github.com/PuerkitoBio/goquery v1.11.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antchfx/htmlquery v1.3.5 // indirect github.com/antchfx/xmlquery v1.5.0 // indirect @@ -34,7 +37,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -65,7 +67,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6f7f5f1..95d59f5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -77,6 +77,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index b684661..333d913 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -137,6 +137,11 @@ func AuthMiddleware() gin.HandlerFunc { } authHeader := c.GetHeader("Authorization") + if authHeader == "" { + if tokenParam := c.Query("token"); tokenParam != "" { + authHeader = "Bearer " + tokenParam + } + } if authHeader == "" { c.JSON(401, gin.H{"error": "Authorization header required"}) c.Abort() @@ -230,6 +235,9 @@ func Register(c *gin.Context) { return } + // Provision messaging defaults (self chat, password vault, global channels). + _ = ensureMessagingDefaults(db, user.ID) + // Generate JWT token token, err := GenerateJWT(user) if err != nil { diff --git a/backend/handlers/messages.go b/backend/handlers/messages.go new file mode 100644 index 0000000..3927869 --- /dev/null +++ b/backend/handlers/messages.go @@ -0,0 +1,2093 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/trackeep/backend/models" + "github.com/trackeep/backend/services" + "github.com/trackeep/backend/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var messageURLRegex = regexp.MustCompile(`https?://[^\s]+`) + +var messagesWSUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type CreateConversationRequest struct { + Type string `json:"type" binding:"required"` + Name string `json:"name"` + Topic string `json:"topic"` + TeamID *uint `json:"team_id"` + UserIDs []uint `json:"user_ids"` +} + +type AddConversationMemberRequest struct { + UserID uint `json:"user_id" binding:"required"` + Role string `json:"role"` +} + +type AttachmentInput struct { + Kind string `json:"kind"` + FileID *uint `json:"file_id"` + URL string `json:"url"` + Title string `json:"title"` +} + +type CreateMessageRequest struct { + Body string `json:"body"` + Attachments []AttachmentInput `json:"attachments"` + Metadata map[string]interface{} `json:"metadata"` +} + +type UpdateMessageRequest struct { + Body string `json:"body" binding:"required"` +} + +type CreateReactionRequest struct { + Emoji string `json:"emoji" binding:"required"` +} + +type MessageSearchRequest struct { + Query string `json:"query"` + ConversationIDs []uint `json:"conversation_ids"` + SenderID *uint `json:"sender_id"` + DateFrom *time.Time `json:"date_from"` + DateTo *time.Time `json:"date_to"` + AttachmentKinds []string `json:"attachment_kinds"` + ReferenceTypes []string `json:"reference_types"` + HasLinks *bool `json:"has_links"` + HasAttachments *bool `json:"has_attachments"` + HasSuggestions *bool `json:"has_suggestions"` + MentionOnly bool `json:"mention_only"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type SuggestionActionRequest struct { + RedactOriginal bool `json:"redact_original"` +} + +type CreateVaultItemRequest struct { + Label string `json:"label" binding:"required"` + Secret string `json:"secret" binding:"required"` + Notes string `json:"notes"` + SourceMessageID *uint `json:"source_message_id"` +} + +type ShareVaultItemRequest struct { + TargetConversationID uint `json:"target_conversation_id" binding:"required"` + ExpiresAt *time.Time `json:"expires_at"` + AllowReveal bool `json:"allow_reveal"` +} + +type UnshareVaultItemRequest struct { + TargetConversationID uint `json:"target_conversation_id"` +} + +type conversationListItem struct { + Conversation models.Conversation `json:"conversation"` + Role string `json:"role"` + UnreadCount int64 `json:"unread_count"` + LastMessage *models.Message `json:"last_message,omitempty"` +} + +// GetConversations lists all conversations for the current user. +func GetConversations(c *gin.Context) { + userID := getAuthUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + if err := ensureMessagingDefaults(models.DB, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize messaging defaults"}) + return + } + + var conversations []models.Conversation + if err := models.DB. + Joins("JOIN conversation_members cm ON cm.conversation_id = conversations.id"). + Where("cm.user_id = ? AND cm.deleted_at IS NULL AND cm.is_hidden = false", userID). + Preload("Members"). + Order("COALESCE(conversations.last_message_at, conversations.updated_at) DESC"). + Find(&conversations).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch conversations"}) + return + } + + items := make([]conversationListItem, 0, len(conversations)) + for _, conv := range conversations { + var membership models.ConversationMember + if err := models.DB.Where("conversation_id = ? AND user_id = ?", conv.ID, userID).First(&membership).Error; err != nil { + continue + } + + var unreadCount int64 + unreadQuery := models.DB.Model(&models.Message{}). + Where("conversation_id = ? AND deleted_at IS NULL AND sender_id <> ?", conv.ID, userID) + if membership.LastReadMessageID != nil { + unreadQuery = unreadQuery.Where("id > ?", *membership.LastReadMessageID) + } + unreadQuery.Count(&unreadCount) + + var lastMessage models.Message + var lastMessagePtr *models.Message + if err := models.DB.Where("conversation_id = ? AND deleted_at IS NULL", conv.ID). + Order("id DESC").Limit(1). + Preload("Sender"). + First(&lastMessage).Error; err == nil { + lastMessagePtr = &lastMessage + } + + items = append(items, conversationListItem{ + Conversation: conv, + Role: string(membership.Role), + UnreadCount: unreadCount, + LastMessage: lastMessagePtr, + }) + } + + c.JSON(http.StatusOK, gin.H{"conversations": items}) +} + +// CreateConversation creates a new conversation (dm/group/team). +func CreateConversation(c *gin.Context) { + userID := getAuthUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var req CreateConversationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + convType := models.ConversationType(req.Type) + switch convType { + case models.ConversationTypeDM, models.ConversationTypeGroup, models.ConversationTypeTeam: + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Only dm, group, and team conversations can be created explicitly"}) + return + } + + if err := ensureMessagingDefaults(models.DB, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize messaging defaults"}) + return + } + + // Direct message conversations are unique per user pair. + if convType == models.ConversationTypeDM { + if len(req.UserIDs) != 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "DM conversation requires exactly one target user_id"}) + return + } + + targetUserID := req.UserIDs[0] + if targetUserID == userID { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot create DM with yourself; use self conversation"}) + return + } + + var target models.User + if err := models.DB.First(&target, targetUserID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Target user not found"}) + return + } + + if existing := findExistingDM(models.DB, userID, targetUserID); existing != nil { + c.JSON(http.StatusOK, gin.H{"conversation": existing}) + return + } + + conv := models.Conversation{ + Type: models.ConversationTypeDM, + Name: req.Name, + Topic: req.Topic, + CreatedBy: userID, + } + if conv.Name == "" { + conv.Name = "Direct Message" + } + + if err := models.DB.Create(&conv).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create conversation"}) + return + } + + members := []models.ConversationMember{ + { + ConversationID: conv.ID, + UserID: userID, + Role: models.ConversationMemberRoleMember, + JoinedAt: time.Now(), + }, + { + ConversationID: conv.ID, + UserID: targetUserID, + Role: models.ConversationMemberRoleMember, + JoinedAt: time.Now(), + }, + } + if err := models.DB.Create(&members).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add members"}) + return + } + + models.DB.Preload("Members").First(&conv, conv.ID) + c.JSON(http.StatusCreated, gin.H{"conversation": conv}) + return + } + + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Conversation name is required"}) + return + } + + conv := models.Conversation{ + Type: convType, + Name: strings.TrimSpace(req.Name), + Topic: req.Topic, + CreatedBy: userID, + } + + memberIDs := make(map[uint]struct{}) + memberIDs[userID] = struct{}{} + + if convType == models.ConversationTypeTeam { + if req.TeamID == nil || *req.TeamID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "team_id is required for team conversations"}) + return + } + + var teamMemberCount int64 + models.DB.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", *req.TeamID, userID).Count(&teamMemberCount) + if teamMemberCount == 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "You must be a team member to create a team conversation"}) + return + } + + conv.TeamID = req.TeamID + + var teamMembers []models.TeamMember + models.DB.Where("team_id = ?", *req.TeamID).Find(&teamMembers) + for _, tm := range teamMembers { + memberIDs[tm.UserID] = struct{}{} + } + } else { + for _, uid := range req.UserIDs { + if uid == 0 { + continue + } + memberIDs[uid] = struct{}{} + } + } + + memberSlice := make([]uint, 0, len(memberIDs)) + for uid := range memberIDs { + memberSlice = append(memberSlice, uid) + } + if !usersExist(models.DB, memberSlice) { + c.JSON(http.StatusBadRequest, gin.H{"error": "One or more users do not exist"}) + return + } + + if err := models.DB.Create(&conv).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create conversation"}) + return + } + + members := make([]models.ConversationMember, 0, len(memberSlice)) + for _, uid := range memberSlice { + role := models.ConversationMemberRoleMember + if uid == userID { + role = models.ConversationMemberRoleOwner + } + members = append(members, models.ConversationMember{ + ConversationID: conv.ID, + UserID: uid, + Role: role, + JoinedAt: time.Now(), + }) + } + if err := models.DB.Create(&members).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add conversation members"}) + return + } + + models.DB.Preload("Members").First(&conv, conv.ID) + services.GetMessagesHub().Broadcast(conv.ID, "conversation.updated", gin.H{"conversation_id": conv.ID}) + c.JSON(http.StatusCreated, gin.H{"conversation": conv}) +} + +// GetConversation retrieves a specific conversation if user has access. +func GetConversation(c *gin.Context) { + userID := getAuthUserID(c) + conversationID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) + return + } + + conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) + return + } + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + var members []models.ConversationMember + models.DB.Where("conversation_id = ?", conversationID). + Preload("User"). + Find(&members) + + c.JSON(http.StatusOK, gin.H{ + "conversation": conv, + "membership": member, + "members": members, + }) +} + +// UpdateConversation updates mutable conversation fields. +func UpdateConversation(c *gin.Context) { + userID := getAuthUserID(c) + conversationID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) + return + } + + conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) + return + } + if !isConversationAdmin(member.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + return + } + + if conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot mutate this conversation"}) + return + } + + var req struct { + Name *string `json:"name"` + Topic *string `json:"topic"` + IsArchived *bool `json:"is_archived"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{} + if req.Name != nil { + updates["name"] = strings.TrimSpace(*req.Name) + } + if req.Topic != nil { + updates["topic"] = *req.Topic + } + if req.IsArchived != nil { + updates["is_archived"] = *req.IsArchived + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No updates provided"}) + return + } + + if err := models.DB.Model(&conv).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update conversation"}) + return + } + + models.DB.First(&conv, conv.ID) + services.GetMessagesHub().Broadcast(conv.ID, "conversation.updated", gin.H{"conversation_id": conv.ID}) + c.JSON(http.StatusOK, gin.H{"conversation": conv}) +} + +// AddConversationMember adds a user to an existing conversation. +func AddConversationMember(c *gin.Context) { + userID := getAuthUserID(c) + conversationID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) + return + } + + conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) + return + } + + if !isConversationAdmin(member.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + return + } + if conv.Type == models.ConversationTypeDM || conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault || conv.Type == models.ConversationTypeGlobal { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot add members to this conversation type"}) + return + } + + var req AddConversationMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var target models.User + if err := models.DB.First(&target, req.UserID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if conv.Type == models.ConversationTypeTeam && conv.TeamID != nil { + var teamMemberCount int64 + models.DB.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", *conv.TeamID, req.UserID).Count(&teamMemberCount) + if teamMemberCount == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "User is not part of this team"}) + return + } + } + + role := models.ConversationMemberRoleMember + if req.Role != "" { + role = models.ConversationMemberRole(req.Role) + } + + memberRow := models.ConversationMember{ + ConversationID: conversationID, + UserID: req.UserID, + Role: role, + JoinedAt: time.Now(), + } + + if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&memberRow).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"}) + return + } + + services.GetMessagesHub().Broadcast(conversationID, "conversation.updated", gin.H{"conversation_id": conversationID}) + c.JSON(http.StatusOK, gin.H{"message": "Member added"}) +} + +// RemoveConversationMember removes a user from a conversation. +func RemoveConversationMember(c *gin.Context) { + userID := getAuthUserID(c) + conversationID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) + return + } + targetUserID, err := parseUintParam(c, "userId") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target user id"}) + return + } + + conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) + return + } + + if conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault || conv.Type == models.ConversationTypeGlobal { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot remove members from this conversation type"}) + return + } + + if targetUserID != userID && !isConversationAdmin(member.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + return + } + + if err := models.DB.Where("conversation_id = ? AND user_id = ?", conversationID, targetUserID).Delete(&models.ConversationMember{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"}) + return + } + + services.GetMessagesHub().Broadcast(conversationID, "conversation.updated", gin.H{"conversation_id": conversationID}) + c.JSON(http.StatusOK, gin.H{"message": "Member removed"}) +} + +// GetConversationMessages fetches messages with cursor-based pagination. +func GetConversationMessages(c *gin.Context) { + userID := getAuthUserID(c) + conversationID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) + return + } + + if _, member, err := getConversationWithMembership(models.DB, conversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } else if member.Role == models.ConversationMemberRoleViewer { + // Viewers can read messages. + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + cursor, _ := strconv.ParseUint(c.DefaultQuery("cursor", "0"), 10, 64) + + query := models.DB.Where("conversation_id = ?", conversationID). + Preload("Sender"). + Preload("Attachments"). + Preload("References"). + Preload("Suggestions"). + Preload("Reactions"). + Order("id DESC"). + Limit(limit) + + if cursor > 0 { + query = query.Where("id < ?", cursor) + } + + var messages []models.Message + if err := query.Find(&messages).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) + return + } + + // Reverse to ascending for timeline rendering. + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + + var nextCursor uint + if len(messages) > 0 { + nextCursor = messages[0].ID + } + + // Update read marker only when this is latest-page fetch. + if cursor == 0 && len(messages) > 0 { + lastID := messages[len(messages)-1].ID + now := time.Now() + models.DB.Model(&models.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Updates(map[string]interface{}{ + "last_read_message_id": lastID, + "last_read_at": &now, + }) + services.GetMessagesHub().Broadcast(conversationID, "read.updated", gin.H{ + "user_id": userID, + "conversation_id": conversationID, + "last_read_message_id": lastID, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "messages": messages, + "next_cursor": nextCursor, + }) +} + +// CreateConversationMessage posts a new message to a conversation. +func CreateConversationMessage(c *gin.Context) { + userID := getAuthUserID(c) + conversationID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) + return + } + + conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + if !canWriteMessage(member.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "You do not have write access in this conversation"}) + return + } + if conv.IsArchived { + c.JSON(http.StatusBadRequest, gin.H{"error": "Conversation is archived"}) + return + } + + var req CreateMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if strings.TrimSpace(req.Body) == "" && len(req.Attachments) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Message body or attachments are required"}) + return + } + + metadataJSON := "{}" + if req.Metadata != nil { + if raw, err := json.Marshal(req.Metadata); err == nil { + metadataJSON = string(raw) + } + } + + message := models.Message{ + ConversationID: conversationID, + SenderID: userID, + Body: strings.TrimSpace(req.Body), + MetadataJSON: metadataJSON, + } + if err := models.DB.Create(&message).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create message"}) + return + } + + attachmentRows := make([]models.MessageAttachment, 0, len(req.Attachments)) + for _, a := range req.Attachments { + attachmentRows = append(attachmentRows, models.MessageAttachment{ + MessageID: message.ID, + Kind: normalizeAttachmentKind(a.Kind), + FileID: a.FileID, + URL: a.URL, + Title: a.Title, + }) + } + + suggestions, inferredAttachments, isSensitive := services.DetectMessageContent(message.Body) + for _, inferred := range inferredAttachments { + if hasAttachment(attachmentRows, inferred.Kind, inferred.URL) { + continue + } + previewJSON := "{}" + if raw, err := json.Marshal(inferred.PreviewMap); err == nil { + previewJSON = string(raw) + } + attachmentRows = append(attachmentRows, models.MessageAttachment{ + MessageID: message.ID, + Kind: normalizeAttachmentKind(inferred.Kind), + URL: inferred.URL, + Title: inferred.Title, + PreviewJSON: previewJSON, + }) + } + if len(attachmentRows) > 0 { + models.DB.Create(&attachmentRows) + } + + if len(suggestions) > 0 { + suggestionRows := make([]models.MessageSuggestion, 0, len(suggestions)) + for _, s := range suggestions { + payloadJSON := "{}" + if raw, err := json.Marshal(s.Payload); err == nil { + payloadJSON = string(raw) + } + suggestionRows = append(suggestionRows, models.MessageSuggestion{ + MessageID: message.ID, + Type: s.Type, + PayloadJSON: payloadJSON, + Status: models.SuggestionStatusPending, + }) + } + models.DB.Create(&suggestionRows) + } + + if isSensitive { + models.DB.Model(&message).Update("is_sensitive", true) + } + + now := time.Now() + models.DB.Model(&models.Conversation{}).Where("id = ?", conversationID).Update("last_message_at", &now) + + var freshMessage models.Message + models.DB. + Preload("Sender"). + Preload("Attachments"). + Preload("References"). + Preload("Suggestions"). + Preload("Reactions"). + First(&freshMessage, message.ID) + + services.GetMessagesHub().Broadcast(conversationID, "message.created", freshMessage) + + response := gin.H{"message": freshMessage} + if isSensitive { + response["warning"] = "Sensitive data detected. We recommend a dedicated password manager like Proton Pass (not affiliated)." + } + c.JSON(http.StatusCreated, response) +} + +// UpdateMessage edits an existing message. +func UpdateMessage(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + + var req UpdateMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + + _, member, err := getConversationWithMembership(models.DB, msg.ConversationID, userID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + if msg.SenderID != userID && !isConversationAdmin(member.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + return + } + if msg.DeletedAt != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Deleted messages cannot be edited"}) + return + } + + now := time.Now() + if err := models.DB.Model(&msg).Updates(map[string]interface{}{ + "body": strings.TrimSpace(req.Body), + "edited_at": &now, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update message"}) + return + } + + models.DB. + Preload("Sender"). + Preload("Attachments"). + Preload("References"). + Preload("Suggestions"). + Preload("Reactions"). + First(&msg, msg.ID) + + services.GetMessagesHub().Broadcast(msg.ConversationID, "message.updated", msg) + c.JSON(http.StatusOK, gin.H{"message": msg}) +} + +// DeleteMessage marks a message as deleted. +func DeleteMessage(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + + _, member, err := getConversationWithMembership(models.DB, msg.ConversationID, userID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + if msg.SenderID != userID && !isConversationAdmin(member.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + return + } + + now := time.Now() + if err := models.DB.Model(&msg).Updates(map[string]interface{}{ + "deleted_at": &now, + "body": "[deleted]", + "edited_at": &now, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"}) + return + } + + services.GetMessagesHub().Broadcast(msg.ConversationID, "message.deleted", gin.H{ + "message_id": msg.ID, + "conversation_id": msg.ConversationID, + }) + c.JSON(http.StatusOK, gin.H{"message": "Message deleted"}) +} + +// AddMessageReaction adds an emoji reaction to a message. +func AddMessageReaction(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + + var req CreateReactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + emoji := strings.TrimSpace(req.Emoji) + if emoji == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"}) + return + } + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + reaction := models.MessageReaction{ + MessageID: messageID, + UserID: userID, + Emoji: emoji, + } + if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&reaction).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reaction"}) + return + } + + models.DB.Where("message_id = ? AND user_id = ? AND emoji = ?", messageID, userID, emoji).First(&reaction) + services.GetMessagesHub().Broadcast(msg.ConversationID, "reaction.added", reaction) + c.JSON(http.StatusOK, gin.H{"reaction": reaction}) +} + +// RemoveMessageReaction removes the current user's reaction from a message. +func RemoveMessageReaction(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + emoji := strings.TrimSpace(c.Param("emoji")) + if emoji == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"}) + return + } + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + if err := models.DB.Where("message_id = ? AND user_id = ? AND emoji = ?", messageID, userID, emoji).Delete(&models.MessageReaction{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reaction"}) + return + } + + services.GetMessagesHub().Broadcast(msg.ConversationID, "reaction.removed", gin.H{ + "message_id": messageID, + "user_id": userID, + "emoji": emoji, + }) + c.JSON(http.StatusOK, gin.H{"message": "Reaction removed"}) +} + +// SearchMessages performs filtered search over messages (excluding password vault). +func SearchMessages(c *gin.Context) { + userID := getAuthUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var req MessageSearchRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 { + req.Limit = 50 + } + if req.Limit > 100 { + req.Limit = 100 + } + if req.Offset < 0 { + req.Offset = 0 + } + + baseQuery := models.DB.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id"). + Where("cm.user_id = ? AND cm.deleted_at IS NULL", userID). + Where("conversations.type <> ?", models.ConversationTypePasswordVault). + Where("messages.deleted_at IS NULL") + + if strings.TrimSpace(req.Query) != "" { + like := "%" + strings.ToLower(strings.TrimSpace(req.Query)) + "%" + baseQuery = baseQuery.Where("LOWER(messages.body) LIKE ?", like) + } + if len(req.ConversationIDs) > 0 { + baseQuery = baseQuery.Where("messages.conversation_id IN ?", req.ConversationIDs) + } + if req.SenderID != nil { + baseQuery = baseQuery.Where("messages.sender_id = ?", *req.SenderID) + } + if req.DateFrom != nil { + baseQuery = baseQuery.Where("messages.created_at >= ?", *req.DateFrom) + } + if req.DateTo != nil { + baseQuery = baseQuery.Where("messages.created_at <= ?", *req.DateTo) + } + if req.MentionOnly { + var user models.User + if err := models.DB.First(&user, userID).Error; err == nil { + mentionNeedle := "%@" + strings.ToLower(user.Username) + "%" + baseQuery = baseQuery.Where("LOWER(messages.body) LIKE ?", mentionNeedle) + } + } + + if req.HasAttachments != nil { + if *req.HasAttachments { + baseQuery = baseQuery.Joins("JOIN message_attachments ma_any ON ma_any.message_id = messages.id") + } else { + baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_attachments ma WHERE ma.message_id = messages.id)") + } + } + + if req.HasLinks != nil { + if *req.HasLinks { + baseQuery = baseQuery.Joins("JOIN message_attachments ma_links ON ma_links.message_id = messages.id AND ma_links.url <> ''") + } else { + baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_attachments ma WHERE ma.message_id = messages.id AND ma.url <> '')") + } + } + + if req.HasSuggestions != nil { + if *req.HasSuggestions { + baseQuery = baseQuery.Joins("JOIN message_suggestions ms_any ON ms_any.message_id = messages.id") + } else { + baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_suggestions ms WHERE ms.message_id = messages.id)") + } + } + + if len(req.AttachmentKinds) > 0 { + baseQuery = baseQuery.Joins("JOIN message_attachments ma_kind ON ma_kind.message_id = messages.id AND ma_kind.kind IN ?", req.AttachmentKinds) + } + + if len(req.ReferenceTypes) > 0 { + baseQuery = baseQuery.Joins("JOIN message_references mr_type ON mr_type.message_id = messages.id AND mr_type.entity_type IN ?", req.ReferenceTypes) + } + + var total int64 + if err := baseQuery.Session(&gorm.Session{}).Distinct("messages.id").Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count messages"}) + return + } + + var messageIDs []uint + if err := baseQuery.Session(&gorm.Session{}). + Distinct("messages.id"). + Order("messages.created_at DESC"). + Offset(req.Offset). + Limit(req.Limit). + Pluck("messages.id", &messageIDs).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"}) + return + } + + if len(messageIDs) == 0 { + c.JSON(http.StatusOK, gin.H{ + "results": []models.Message{}, + "total": total, + "limit": req.Limit, + "offset": req.Offset, + }) + return + } + + var messages []models.Message + if err := models.DB. + Where("id IN ?", messageIDs). + Preload("Sender"). + Preload("Attachments"). + Preload("References"). + Preload("Suggestions"). + Preload("Reactions"). + Find(&messages).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"}) + return + } + + order := make(map[uint]int, len(messageIDs)) + for i, id := range messageIDs { + order[id] = i + } + sort.Slice(messages, func(i, j int) bool { + return order[messages[i].ID] < order[messages[j].ID] + }) + + c.JSON(http.StatusOK, gin.H{ + "results": messages, + "total": total, + "limit": req.Limit, + "offset": req.Offset, + }) +} + +// GetMessageSuggestions returns suggestions for a specific message. +func GetMessageSuggestions(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + var suggestions []models.MessageSuggestion + if err := models.DB.Where("message_id = ?", messageID).Order("id ASC").Find(&suggestions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch suggestions"}) + return + } + c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) +} + +// AcceptMessageSuggestion applies a suggestion action and marks it accepted. +func AcceptMessageSuggestion(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + suggestionID, err := parseUintParam(c, "suggestionId") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid suggestion id"}) + return + } + + var req SuggestionActionRequest + _ = c.ShouldBindJSON(&req) + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + var suggestion models.MessageSuggestion + if err := models.DB.Where("id = ? AND message_id = ?", suggestionID, messageID).First(&suggestion).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) + return + } + + if suggestion.Status != models.SuggestionStatusPending { + c.JSON(http.StatusBadRequest, gin.H{"error": "Suggestion is not pending"}) + return + } + + result, err := applySuggestionAction(models.DB, userID, &msg, &suggestion, req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + suggestion.Status = models.SuggestionStatusAccepted + models.DB.Save(&suggestion) + + services.GetMessagesHub().Broadcast(msg.ConversationID, "conversation.updated", gin.H{ + "message_id": msg.ID, + "suggestion_id": suggestion.ID, + "status": suggestion.Status, + }) + + c.JSON(http.StatusOK, gin.H{ + "message": "Suggestion accepted", + "result": result, + "suggestion": suggestion, + }) +} + +// DismissMessageSuggestion marks a suggestion dismissed. +func DismissMessageSuggestion(c *gin.Context) { + userID := getAuthUserID(c) + messageID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) + return + } + suggestionID, err := parseUintParam(c, "suggestionId") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid suggestion id"}) + return + } + + var msg models.Message + if err := models.DB.First(&msg, messageID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) + return + } + if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + var suggestion models.MessageSuggestion + if err := models.DB.Where("id = ? AND message_id = ?", suggestionID, messageID).First(&suggestion).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) + return + } + + suggestion.Status = models.SuggestionStatusDismissed + if err := models.DB.Save(&suggestion).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to dismiss suggestion"}) + return + } + + c.JSON(http.StatusOK, gin.H{"suggestion": suggestion}) +} + +// GetPasswordVaultItems returns owned and explicitly shared vault items. +func GetPasswordVaultItems(c *gin.Context) { + userID := getAuthUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + type vaultItemResponse struct { + ID uint `json:"id"` + Label string `json:"label"` + OwnerUserID uint `json:"owner_user_id"` + SourceMessageID *uint `json:"source_message_id,omitempty"` + LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` + Shared bool `json:"shared"` + AllowReveal bool `json:"allow_reveal"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + TargetConversationID *uint `json:"target_conversation_id,omitempty"` + } + + results := make([]vaultItemResponse, 0, 32) + + var owned []models.PasswordVaultItem + models.DB.Where("owner_user_id = ?", userID).Find(&owned) + for _, item := range owned { + results = append(results, vaultItemResponse{ + ID: item.ID, + Label: item.Label, + OwnerUserID: item.OwnerUserID, + SourceMessageID: item.SourceMessageID, + LastAccessedAt: item.LastAccessedAt, + Shared: false, + AllowReveal: true, + }) + } + + var shares []models.PasswordVaultShare + models.DB. + Joins("JOIN conversation_members cm ON cm.conversation_id = password_vault_shares.target_conversation_id"). + Where("cm.user_id = ? AND cm.deleted_at IS NULL", userID). + Where("password_vault_shares.expires_at IS NULL OR password_vault_shares.expires_at > ?", time.Now()). + Preload("VaultItem"). + Find(&shares) + + seen := map[uint]bool{} + for _, ownedItem := range owned { + seen[ownedItem.ID] = true + } + + for _, share := range shares { + item := share.VaultItem + if seen[item.ID] { + continue + } + seen[item.ID] = true + targetID := share.TargetConversationID + results = append(results, vaultItemResponse{ + ID: item.ID, + Label: item.Label, + OwnerUserID: item.OwnerUserID, + SourceMessageID: item.SourceMessageID, + LastAccessedAt: item.LastAccessedAt, + Shared: true, + AllowReveal: share.AllowReveal, + ExpiresAt: share.ExpiresAt, + TargetConversationID: &targetID, + }) + } + + c.JSON(http.StatusOK, gin.H{"items": results}) +} + +// CreatePasswordVaultItem creates a new encrypted vault item. +func CreatePasswordVaultItem(c *gin.Context) { + userID := getAuthUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var req CreateVaultItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + encryptedSecret, err := utils.Encrypt(req.Secret) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt secret"}) + return + } + encryptedNotes := "" + if strings.TrimSpace(req.Notes) != "" { + encryptedNotes, err = utils.Encrypt(req.Notes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt notes"}) + return + } + } + + item := models.PasswordVaultItem{ + OwnerUserID: userID, + Label: strings.TrimSpace(req.Label), + EncryptedSecret: encryptedSecret, + EncryptedNotes: encryptedNotes, + SourceMessageID: req.SourceMessageID, + CreatedBy: userID, + } + if err := models.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create vault item"}) + return + } + + if req.SourceMessageID != nil && *req.SourceMessageID > 0 { + models.DB.Model(&models.Message{}).Where("id = ?", *req.SourceMessageID).Update("is_sensitive", true) + } + + c.JSON(http.StatusCreated, gin.H{ + "item": gin.H{ + "id": item.ID, + "label": item.Label, + "owner_user_id": item.OwnerUserID, + "source_message_id": item.SourceMessageID, + }, + }) +} + +// SharePasswordVaultItem shares an item to a selected conversation. +func SharePasswordVaultItem(c *gin.Context) { + userID := getAuthUserID(c) + itemID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) + return + } + + var req ShareVaultItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var item models.PasswordVaultItem + if err := models.DB.First(&item, itemID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) + return + } + if item.OwnerUserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can share vault items"}) + return + } + + if _, _, err := getConversationWithMembership(models.DB, req.TargetConversationID, userID); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "You are not a member of target conversation"}) + return + } + + var share models.PasswordVaultShare + err = models.DB.Where("vault_item_id = ? AND target_conversation_id = ?", itemID, req.TargetConversationID).First(&share).Error + if err == nil { + share.ExpiresAt = req.ExpiresAt + share.AllowReveal = req.AllowReveal + share.SharedByUserID = userID + models.DB.Save(&share) + } else { + share = models.PasswordVaultShare{ + VaultItemID: itemID, + SharedByUserID: userID, + TargetConversationID: req.TargetConversationID, + ExpiresAt: req.ExpiresAt, + AllowReveal: req.AllowReveal, + } + if err := models.DB.Create(&share).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to share vault item"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"share": share}) +} + +// RevealPasswordVaultItem decrypts and returns a vault item secret if allowed. +func RevealPasswordVaultItem(c *gin.Context) { + userID := getAuthUserID(c) + itemID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) + return + } + + var item models.PasswordVaultItem + if err := models.DB.First(&item, itemID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) + return + } + + allowed := item.OwnerUserID == userID + if !allowed { + var share models.PasswordVaultShare + err := models.DB. + Joins("JOIN conversation_members cm ON cm.conversation_id = password_vault_shares.target_conversation_id"). + Where("password_vault_shares.vault_item_id = ? AND cm.user_id = ? AND cm.deleted_at IS NULL", itemID, userID). + Where("password_vault_shares.allow_reveal = true"). + Where("password_vault_shares.expires_at IS NULL OR password_vault_shares.expires_at > ?", time.Now()). + First(&share).Error + if err == nil { + allowed = true + } + } + + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "You are not allowed to reveal this vault item"}) + return + } + + secret, err := utils.Decrypt(item.EncryptedSecret) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decrypt secret"}) + return + } + notes := "" + if strings.TrimSpace(item.EncryptedNotes) != "" { + notes, _ = utils.Decrypt(item.EncryptedNotes) + } + + now := time.Now() + models.DB.Model(&item).Update("last_accessed_at", &now) + + c.JSON(http.StatusOK, gin.H{ + "id": item.ID, + "label": item.Label, + "secret": secret, + "notes": notes, + "warning": "Use a dedicated password manager like Proton Pass (not affiliated) for best security hygiene.", + }) +} + +// UnsharePasswordVaultItem revokes one or all shares for an item. +func UnsharePasswordVaultItem(c *gin.Context) { + userID := getAuthUserID(c) + itemID, err := parseUintParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) + return + } + + var req UnshareVaultItemRequest + _ = c.ShouldBindJSON(&req) + + var item models.PasswordVaultItem + if err := models.DB.First(&item, itemID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) + return + } + if item.OwnerUserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can unshare vault items"}) + return + } + + query := models.DB.Where("vault_item_id = ? AND shared_by_user_id = ?", itemID, userID) + if req.TargetConversationID > 0 { + query = query.Where("target_conversation_id = ?", req.TargetConversationID) + } + if err := query.Delete(&models.PasswordVaultShare{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unshare vault item"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Vault share removed"}) +} + +// MessagesWebSocket upgrades to websocket and handles realtime messaging events. +func MessagesWebSocket(c *gin.Context) { + userID := getAuthUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + conn, err := messagesWSUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + + hub := services.GetMessagesHub() + client := services.NewWSClient(userID, conn) + + // Subscribe to all conversations this user is a member of. + var memberships []models.ConversationMember + models.DB.Where("user_id = ?", userID).Find(&memberships) + for _, m := range memberships { + hub.AddClientToConversation(client, m.ConversationID) + } + + // Writer loop + go func() { + ticker := time.NewTicker(25 * time.Second) + defer ticker.Stop() + for { + select { + case raw, ok := <-client.Send: + if !ok { + _ = conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { + return + } + case <-ticker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } + }() + + // Reader loop + for { + var incoming map[string]interface{} + if err := conn.ReadJSON(&incoming); err != nil { + break + } + + eventType, _ := incoming["type"].(string) + conversationID := parseUintAny(incoming["conversation_id"]) + + switch eventType { + case "subscribe": + if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { + hub.AddClientToConversation(client, conversationID) + } + case "unsubscribe": + if conversationID > 0 { + hub.RemoveClientFromConversation(client, conversationID) + } + case "typing.started", "typing.stopped": + if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { + hub.Broadcast(conversationID, eventType, gin.H{ + "user_id": userID, + "conversation_id": conversationID, + }) + } + case "call.offer", "call.answer", "call.ice": + targetUserID := parseUintAny(incoming["target_user_id"]) + if conversationID > 0 && targetUserID > 0 && isConversationMember(models.DB, conversationID, userID) && isConversationMember(models.DB, conversationID, targetUserID) { + payload := gin.H{ + "conversation_id": conversationID, + "sender_id": userID, + "target_user_id": targetUserID, + } + if sdp, exists := incoming["sdp"]; exists { + payload["sdp"] = sdp + } + if candidate, exists := incoming["candidate"]; exists { + payload["candidate"] = candidate + } + if callID, exists := incoming["call_id"]; exists { + payload["call_id"] = callID + } + hub.SendToUser(conversationID, targetUserID, eventType, payload) + } + case "call.hangup": + if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { + payload := gin.H{ + "conversation_id": conversationID, + "user_id": userID, + } + if callID, exists := incoming["call_id"]; exists { + payload["call_id"] = callID + } + hub.Broadcast(conversationID, "call.hangup", payload) + } + case "read.updated": + if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { + lastReadID := parseUintAny(incoming["last_read_message_id"]) + now := time.Now() + models.DB.Model(&models.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Updates(map[string]interface{}{ + "last_read_message_id": lastReadID, + "last_read_at": &now, + }) + hub.Broadcast(conversationID, "read.updated", gin.H{ + "user_id": userID, + "conversation_id": conversationID, + "last_read_message_id": lastReadID, + }) + } + } + } + + hub.RemoveClient(client) + _ = conn.Close() +} + +func applySuggestionAction(db *gorm.DB, userID uint, message *models.Message, suggestion *models.MessageSuggestion, req SuggestionActionRequest) (interface{}, error) { + payload := map[string]interface{}{} + if strings.TrimSpace(suggestion.PayloadJSON) != "" { + _ = json.Unmarshal([]byte(suggestion.PayloadJSON), &payload) + } + + switch suggestion.Type { + case "create_task": + title := asString(payload["title"]) + if strings.TrimSpace(title) == "" { + title = compactMessageTitle(message.Body, 80) + } + task := models.Task{ + UserID: userID, + Title: title, + Description: message.Body, + Status: models.TaskStatusPending, + Priority: models.TaskPriorityMedium, + } + if err := db.Create(&task).Error; err != nil { + return nil, err + } + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "task", + EntityID: task.ID, + DeepLink: "/app/tasks?id=" + strconv.Itoa(int(task.ID)), + } + db.Create(&ref) + return gin.H{"task": task, "deep_link": ref.DeepLink}, nil + + case "create_event": + title := asString(payload["title"]) + if strings.TrimSpace(title) == "" { + title = compactMessageTitle(message.Body, 80) + } + start := time.Now().Add(1 * time.Hour) + end := start.Add(1 * time.Hour) + event := models.CalendarEvent{ + UserID: userID, + Title: title, + Description: message.Body, + StartTime: start, + EndTime: end, + Type: "reminder", + Priority: "medium", + Source: "trackeep", + ReminderMinutes: 15, + } + if err := db.Create(&event).Error; err != nil { + return nil, err + } + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "calendar_event", + EntityID: event.ID, + DeepLink: "/app/calendar?eventId=" + strconv.Itoa(int(event.ID)), + } + db.Create(&ref) + return gin.H{"event": event, "deep_link": ref.DeepLink}, nil + + case "save_bookmark": + url := asString(payload["url"]) + if strings.TrimSpace(url) == "" { + url = firstURLFromText(message.Body) + } + if strings.TrimSpace(url) == "" { + return nil, errors.New("no URL available for bookmark suggestion") + } + title := asString(payload["title"]) + if strings.TrimSpace(title) == "" { + title = url + } + bookmark := models.Bookmark{ + UserID: userID, + Title: title, + URL: url, + Description: "Created from chat suggestion", + } + if err := db.Create(&bookmark).Error; err != nil { + return nil, err + } + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "bookmark", + EntityID: bookmark.ID, + DeepLink: "/app/bookmarks?id=" + strconv.Itoa(int(bookmark.ID)), + } + db.Create(&ref) + return gin.H{"bookmark": bookmark, "deep_link": ref.DeepLink}, nil + + case "save_youtube": + url := asString(payload["url"]) + if strings.TrimSpace(url) == "" { + url = firstURLFromText(message.Body) + } + videoID := extractYouTubeVideoID(url) + if videoID == "" { + return nil, errors.New("no valid YouTube URL found") + } + + recordVideoID := videoID + var existing models.VideoBookmark + if err := db.Where("video_id = ?", recordVideoID).First(&existing).Error; err == nil && existing.UserID != userID { + recordVideoID = videoID + "-" + strconv.Itoa(int(userID)) + } + + video := models.VideoBookmark{ + UserID: userID, + VideoID: recordVideoID, + Title: compactMessageTitle(message.Body, 80), + Channel: "Unknown", + Thumbnail: "https://img.youtube.com/vi/" + videoID + "/hqdefault.jpg", + URL: url, + Description: "Saved from chat suggestion", + } + if err := db.Create(&video).Error; err != nil { + return nil, err + } + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "youtube_video", + EntityID: video.ID, + DeepLink: "/app/youtube?video=" + videoID, + } + db.Create(&ref) + return gin.H{"video": video, "deep_link": ref.DeepLink}, nil + + case "save_search": + queryText := asString(payload["query"]) + if strings.TrimSpace(queryText) == "" { + queryText = message.Body + } + saved := models.SavedSearch{ + UserID: userID, + Name: compactMessageTitle(queryText, 50), + Query: queryText, + Filters: "{}", + Alert: false, + Description: "Saved from chat suggestion", + } + if err := db.Create(&saved).Error; err != nil { + return nil, err + } + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "saved_search", + EntityID: saved.ID, + DeepLink: "/app/search?savedId=" + strconv.Itoa(int(saved.ID)), + } + db.Create(&ref) + return gin.H{"saved_search": saved, "deep_link": ref.DeepLink}, nil + + case "link_github": + url := asString(payload["url"]) + if strings.TrimSpace(url) == "" { + url = firstURLFromText(message.Body) + } + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "github", + EntityID: 0, + DeepLink: "/app/github", + } + db.Create(&ref) + return gin.H{"github_url": url, "deep_link": ref.DeepLink}, nil + + case "link_learning_path": + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "learning_path", + EntityID: 0, + DeepLink: "/app/learning-paths", + } + db.Create(&ref) + return gin.H{"deep_link": ref.DeepLink}, nil + + case "move_to_password_vault": + label := "Imported from chat" + if compact := compactMessageTitle(message.Body, 50); compact != "" { + label = compact + } + encryptedSecret, err := utils.Encrypt(message.Body) + if err != nil { + return nil, err + } + encryptedNotes, _ := utils.Encrypt("Imported from message #" + strconv.Itoa(int(message.ID))) + item := models.PasswordVaultItem{ + OwnerUserID: userID, + Label: label, + EncryptedSecret: encryptedSecret, + EncryptedNotes: encryptedNotes, + SourceMessageID: &message.ID, + CreatedBy: userID, + } + if err := db.Create(&item).Error; err != nil { + return nil, err + } + + redactOriginal := req.RedactOriginal + if req.RedactOriginal == false { + // Default to redacting when vault action is accepted. + redactOriginal = true + } + if redactOriginal { + now := time.Now() + db.Model(&models.Message{}).Where("id = ?", message.ID).Updates(map[string]interface{}{ + "body": "[moved to vault]", + "is_sensitive": true, + "edited_at": &now, + }) + } + + ref := models.MessageReference{ + MessageID: message.ID, + EntityType: "password_vault_item", + EntityID: item.ID, + DeepLink: "/app/messages?vaultItem=" + strconv.Itoa(int(item.ID)), + } + db.Create(&ref) + return gin.H{"vault_item_id": item.ID, "deep_link": ref.DeepLink}, nil + + case "password_warning": + return gin.H{ + "warning": "Sensitive content detected. We recommend Proton Pass (not affiliated).", + }, nil + } + + return nil, errors.New("unsupported suggestion type") +} + +func ensureMessagingDefaults(db *gorm.DB, userID uint) error { + globals, err := ensureGlobalConversations(db, userID) + if err != nil { + return err + } + + // Ensure the current user is in all global channels. + for _, conv := range globals { + member := models.ConversationMember{ + ConversationID: conv.ID, + UserID: userID, + Role: models.ConversationMemberRoleMember, + JoinedAt: time.Now(), + } + if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { + return err + } + } + + if _, err := ensureUserConversation(db, userID, models.ConversationTypeSelf, "Notes to Self", true); err != nil { + return err + } + if _, err := ensureUserConversation(db, userID, models.ConversationTypePasswordVault, "Password Vault", true); err != nil { + return err + } + + return nil +} + +func ensureGlobalConversations(db *gorm.DB, userID uint) ([]models.Conversation, error) { + defaults := []string{"#general", "#announcements"} + out := make([]models.Conversation, 0, len(defaults)) + + for _, name := range defaults { + var conv models.Conversation + err := db.Where("type = ? AND name = ?", models.ConversationTypeGlobal, name).First(&conv).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + conv = models.Conversation{ + Type: models.ConversationTypeGlobal, + Name: name, + IsDefault: true, + CreatedBy: userID, + } + if err := db.Create(&conv).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + out = append(out, conv) + } + + // Backfill global membership for all users. + var users []models.User + if err := db.Find(&users).Error; err != nil { + return nil, err + } + rows := make([]models.ConversationMember, 0, len(users)*len(out)) + for _, conv := range out { + for _, user := range users { + rows = append(rows, models.ConversationMember{ + ConversationID: conv.ID, + UserID: user.ID, + Role: models.ConversationMemberRoleMember, + JoinedAt: time.Now(), + }) + } + } + if len(rows) > 0 { + if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil { + return nil, err + } + } + + return out, nil +} + +func ensureUserConversation(db *gorm.DB, userID uint, conversationType models.ConversationType, name string, isDefault bool) (*models.Conversation, error) { + var conv models.Conversation + err := db. + Where("type = ? AND created_by = ?", conversationType, userID). + First(&conv).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + conv = models.Conversation{ + Type: conversationType, + Name: name, + IsDefault: isDefault, + CreatedBy: userID, + } + if err := db.Create(&conv).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + member := models.ConversationMember{ + ConversationID: conv.ID, + UserID: userID, + Role: models.ConversationMemberRoleOwner, + JoinedAt: time.Now(), + } + if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { + return nil, err + } + + return &conv, nil +} + +func getConversationWithMembership(db *gorm.DB, conversationID, userID uint) (*models.Conversation, *models.ConversationMember, error) { + var conv models.Conversation + if err := db.First(&conv, conversationID).Error; err != nil { + return nil, nil, err + } + + var member models.ConversationMember + if err := db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&member).Error; err != nil { + return nil, nil, err + } + return &conv, &member, nil +} + +func isConversationAdmin(role models.ConversationMemberRole) bool { + return role == models.ConversationMemberRoleOwner || role == models.ConversationMemberRoleAdmin +} + +func canWriteMessage(role models.ConversationMemberRole) bool { + return role == models.ConversationMemberRoleOwner || + role == models.ConversationMemberRoleAdmin || + role == models.ConversationMemberRoleMember +} + +func getAuthUserID(c *gin.Context) uint { + if uid := c.GetUint("user_id"); uid != 0 { + return uid + } + if uid := c.GetUint("userID"); uid != 0 { + return uid + } + return 0 +} + +func parseUintParam(c *gin.Context, key string) (uint, error) { + raw := c.Param(key) + parsed, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + return 0, err + } + return uint(parsed), nil +} + +func parseUintAny(v interface{}) uint { + switch t := v.(type) { + case float64: + return uint(t) + case int: + return uint(t) + case uint: + return t + case string: + p, _ := strconv.ParseUint(t, 10, 32) + return uint(p) + default: + return 0 + } +} + +func findExistingDM(db *gorm.DB, userA, userB uint) *models.Conversation { + var candidates []models.Conversation + db.Joins("JOIN conversation_members cm ON cm.conversation_id = conversations.id"). + Where("conversations.type = ? AND cm.user_id = ?", models.ConversationTypeDM, userA). + Find(&candidates) + + for _, conv := range candidates { + var members []models.ConversationMember + db.Where("conversation_id = ?", conv.ID).Find(&members) + if len(members) != 2 { + continue + } + foundA, foundB := false, false + for _, m := range members { + if m.UserID == userA { + foundA = true + } + if m.UserID == userB { + foundB = true + } + } + if foundA && foundB { + return &conv + } + } + return nil +} + +func usersExist(db *gorm.DB, userIDs []uint) bool { + if len(userIDs) == 0 { + return true + } + var count int64 + db.Model(&models.User{}).Where("id IN ?", userIDs).Count(&count) + return int(count) == len(userIDs) +} + +func hasAttachment(rows []models.MessageAttachment, kind, url string) bool { + for _, row := range rows { + if strings.EqualFold(row.Kind, kind) && strings.EqualFold(strings.TrimSpace(row.URL), strings.TrimSpace(url)) { + return true + } + } + return false +} + +func normalizeAttachmentKind(kind string) string { + k := strings.ToLower(strings.TrimSpace(kind)) + switch k { + case "file", "image", "youtube", "github", "website", "bookmark", "task", "event", "calendar", "activity", "learning_path", "saved_search", "voice_note": + return k + default: + return "website" + } +} + +func compactMessageTitle(text string, limit int) string { + trimmed := strings.TrimSpace(text) + if len(trimmed) <= limit { + return trimmed + } + if limit < 4 { + return trimmed + } + return strings.TrimSpace(trimmed[:limit-3]) + "..." +} + +func firstURLFromText(text string) string { + matches := messageURLRegex.FindAllString(text, -1) + if len(matches) == 0 { + return "" + } + return matches[0] +} + +func extractYouTubeVideoID(rawURL string) string { + if strings.Contains(rawURL, "youtu.be/") { + parts := strings.Split(rawURL, "youtu.be/") + if len(parts) > 1 { + idPart := parts[1] + if idx := strings.IndexAny(idPart, "?&/"); idx > -1 { + idPart = idPart[:idx] + } + return idPart + } + } + if strings.Contains(rawURL, "youtube.com/watch?v=") { + parts := strings.Split(rawURL, "watch?v=") + if len(parts) > 1 { + idPart := parts[1] + if idx := strings.IndexAny(idPart, "&/"); idx > -1 { + idPart = idPart[:idx] + } + return idPart + } + } + return "" +} + +func asString(v interface{}) string { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func isConversationMember(db *gorm.DB, conversationID, userID uint) bool { + var count int64 + db.Model(&models.ConversationMember{}).Where("conversation_id = ? AND user_id = ?", conversationID, userID).Count(&count) + return count > 0 +} diff --git a/backend/handlers/semantic_search.go b/backend/handlers/semantic_search.go index aaf2e7a..f48f2bd 100644 --- a/backend/handlers/semantic_search.go +++ b/backend/handlers/semantic_search.go @@ -17,7 +17,7 @@ import ( // SemanticSearchRequest represents a semantic search request type SemanticSearchRequest struct { Query string `json:"query" binding:"required"` - ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files' + ContentType string `json:"content_type"` // all | bookmarks | tasks | notes | files | calendar_events | youtube_videos | learning_paths | chat_messages Limit int `json:"limit"` Threshold float64 `json:"threshold"` // Similarity threshold (0-1) } @@ -32,24 +32,24 @@ type SemanticSearchResponse struct { // SemanticSearchResult represents a semantic search result type SemanticSearchResult struct { - ID uint `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - Description string `json:"description"` - Content string `json:"content"` - Similarity float64 `json:"similarity"` - Highlights []string `json:"highlights"` - Tags []models.Tag `json:"tags,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - URL string `json:"url,omitempty"` - Status string `json:"status,omitempty"` - Priority string `json:"priority,omitempty"` + ID uint `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + Content string `json:"content"` + Similarity float64 `json:"similarity"` + Highlights []string `json:"highlights"` + Tags []models.Tag `json:"tags,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + URL string `json:"url,omitempty"` + Status string `json:"status,omitempty"` + Priority string `json:"priority,omitempty"` } // GenerateEmbeddingRequest represents request to generate embeddings type GenerateEmbeddingRequest struct { - Text string `json:"text" binding:"required"` + Text string `json:"text" binding:"required"` ContentType string `json:"content_type"` ContentID uint `json:"content_id"` } @@ -87,7 +87,7 @@ func SemanticSearch(c *gin.Context) { queryEmbedding, err := generateEmbedding(req.Query) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to generate query embedding", + "error": "Failed to generate query embedding", "details": err.Error(), }) return @@ -97,7 +97,7 @@ func SemanticSearch(c *gin.Context) { results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to search similar content", + "error": "Failed to search similar content", "details": err.Error(), }) return @@ -127,7 +127,7 @@ func GenerateEmbedding(c *gin.Context) { embedding, err := generateEmbedding(req.Text) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to generate embedding", + "error": "Failed to generate embedding", "details": err.Error(), }) return @@ -139,15 +139,15 @@ func GenerateEmbedding(c *gin.Context) { userID := c.GetUint("user_id") embeddingJSON, _ := json.Marshal(embedding) - + contentEmbedding := models.ContentEmbedding{ - ContentType: req.ContentType, - ContentID: req.ContentID, - Embedding: string(embeddingJSON), - Model: "text-embedding-ada-002", - Dimensions: len(embedding), - TextContent: req.Text, - UserID: userID, + ContentType: req.ContentType, + ContentID: req.ContentID, + Embedding: string(embeddingJSON), + Model: "text-embedding-ada-002", + Dimensions: len(embedding), + TextContent: req.Text, + UserID: userID, } if err := db.Create(&contentEmbedding).Error; err != nil { @@ -179,7 +179,7 @@ func ReindexContent(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Content reindexing started in background", - "status": "processing", + "status": "processing", }) } @@ -188,13 +188,13 @@ func generateEmbedding(text string) ([]float64, error) { // TODO: Replace with actual OpenAI API call // For now, return a mock embedding for demonstration embedding := make([]float64, 1536) // OpenAI embedding dimensions - + // Generate pseudo-random but deterministic embedding based on text hash := simpleHash(text) for i := range embedding { embedding[i] = math.Sin(float64(hash+i)) * 0.5 } - + return embedding, nil } @@ -214,11 +214,11 @@ func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, cont // Get all embeddings for the user var embeddings []models.ContentEmbedding query := db.Where("user_id = ?", userID) - + if contentType != "all" && contentType != "" { - query = query.Where("content_type = ?", contentType) + query = query.Where("content_type = ?", normalizeSemanticContentType(contentType)) } - + if err := query.Find(&embeddings).Error; err != nil { return results, err } @@ -228,15 +228,15 @@ func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, cont embedding models.ContentEmbedding score float64 } - + var scores []similarityScore - + for _, embedding := range embeddings { var storedEmbedding []float64 if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil { continue } - + similarity := cosineSimilarity(queryEmbedding, storedEmbedding) if similarity >= threshold { scores = append(scores, similarityScore{ @@ -279,17 +279,17 @@ func cosineSimilarity(a, b []float64) float64 { } var dotProduct, normA, normB float64 - + for i := range a { dotProduct += a[i] * b[i] normA += a[i] * a[i] normB += b[i] * b[i] } - + if normA == 0 || normB == 0 { return 0 } - + return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB)) } @@ -305,7 +305,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil { return result, err } - + result.ID = bookmark.ID result.Type = "bookmark" result.Title = bookmark.Title @@ -321,7 +321,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil { return result, err } - + result.ID = task.ID result.Type = "task" result.Title = task.Title @@ -337,7 +337,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil { return result, err } - + result.ID = note.ID result.Type = "note" result.Title = note.Title @@ -352,7 +352,7 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil { return result, err } - + result.ID = file.ID result.Type = "file" result.Title = file.OriginalName @@ -361,6 +361,68 @@ func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, s result.Tags = file.Tags result.CreatedAt = file.CreatedAt result.UpdatedAt = file.UpdatedAt + + case "calendar_event": + var event models.CalendarEvent + if err := db.First(&event, embedding.ContentID).Error; err != nil { + return result, err + } + + result.ID = event.ID + result.Type = "calendar_event" + result.Title = event.Title + result.Description = event.Description + result.Content = event.Description + result.CreatedAt = event.CreatedAt + result.UpdatedAt = event.UpdatedAt + result.Priority = event.Priority + + case "youtube_video": + var video models.VideoBookmark + if err := db.First(&video, embedding.ContentID).Error; err != nil { + return result, err + } + + result.ID = video.ID + result.Type = "youtube_video" + result.Title = video.Title + result.Description = video.Description + result.Content = video.Description + result.CreatedAt = video.CreatedAt + result.UpdatedAt = video.UpdatedAt + result.URL = video.URL + + case "learning_path": + var path models.LearningPath + if err := db.First(&path, embedding.ContentID).Error; err != nil { + return result, err + } + + result.ID = path.ID + result.Type = "learning_path" + result.Title = path.Title + result.Description = path.Description + result.Content = path.Description + result.CreatedAt = path.CreatedAt + result.UpdatedAt = path.UpdatedAt + + case "chat_message": + var message models.Message + if err := db.First(&message, embedding.ContentID).Error; err != nil { + return result, err + } + if message.IsSensitive { + return result, fmt.Errorf("sensitive message excluded from semantic search") + } + + result.ID = message.ID + result.Type = "chat_message" + result.Title = "Chat message" + result.Description = compactSemanticText(message.Body, 140) + result.Content = message.Body + result.CreatedAt = message.CreatedAt + result.UpdatedAt = message.UpdatedAt + result.URL = fmt.Sprintf("/app/messages?conversationId=%d&messageId=%d", message.ConversationID, message.ID) } // Generate highlights (simplified) @@ -402,35 +464,139 @@ func reindexUserContent(db *gorm.DB, userID uint) { // Reindex bookmarks var bookmarks []models.Bookmark db.Where("user_id = ?", userID).Find(&bookmarks) - + for _, bookmark := range bookmarks { text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content - embedding, err := generateEmbedding(text) - if err != nil { - continue - } - - embeddingJSON, _ := json.Marshal(embedding) - - contentEmbedding := models.ContentEmbedding{ - ContentType: "bookmark", - ContentID: bookmark.ID, - Embedding: string(embeddingJSON), - Model: "text-embedding-ada-002", - Dimensions: len(embedding), - TextContent: text, - UserID: userID, - } - - // Delete existing embedding for this content - db.Where("content_type = ? AND content_id = ?", "bookmark", bookmark.ID).Delete(&models.ContentEmbedding{}) - - // Create new embedding - db.Create(&contentEmbedding) + upsertEmbedding(db, userID, "bookmark", bookmark.ID, text) } - // Similar reindexing for tasks, notes, files... - // TODO: Implement reindexing for other content types + // Tasks + var tasks []models.Task + db.Where("user_id = ?", userID).Find(&tasks) + for _, task := range tasks { + text := task.Title + " " + task.Description + upsertEmbedding(db, userID, "task", task.ID, text) + } + + // Notes + var notes []models.Note + db.Where("user_id = ?", userID).Find(¬es) + for _, note := range notes { + if note.IsEncrypted { + continue + } + text := note.Title + " " + note.Description + " " + note.Content + upsertEmbedding(db, userID, "note", note.ID, text) + } + + // Files + var files []models.File + db.Where("user_id = ?", userID).Find(&files) + for _, file := range files { + text := file.OriginalName + " " + file.Description + " " + file.Content + upsertEmbedding(db, userID, "file", file.ID, text) + } + + // Calendar events + var events []models.CalendarEvent + db.Where("user_id = ?", userID).Find(&events) + for _, event := range events { + text := event.Title + " " + event.Description + " " + event.Type + " " + event.Priority + upsertEmbedding(db, userID, "calendar_event", event.ID, text) + } + + // YouTube bookmarks + var videos []models.VideoBookmark + db.Where("user_id = ?", userID).Find(&videos) + for _, video := range videos { + text := video.Title + " " + video.Description + " " + video.Channel + " " + video.URL + upsertEmbedding(db, userID, "youtube_video", video.ID, text) + } + + // Learning paths + var learningPaths []models.LearningPath + db.Where("creator_id = ?", userID).Find(&learningPaths) + for _, path := range learningPaths { + text := path.Title + " " + path.Description + " " + path.Category + " " + path.Difficulty + upsertEmbedding(db, userID, "learning_path", path.ID, text) + } + + // Chat messages (skip sensitive/vault content) + var messages []models.Message + db.Model(&models.Message{}). + Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id"). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("cm.user_id = ?", userID). + Where("conversations.type <> ?", models.ConversationTypePasswordVault). + Where("messages.deleted_at IS NULL"). + Find(&messages) + for _, message := range messages { + if message.IsSensitive { + continue + } + upsertEmbedding(db, userID, "chat_message", message.ID, message.Body) + } fmt.Printf("Reindexing completed for user %d\n", userID) } + +func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + + embedding, err := generateEmbedding(text) + if err != nil { + return + } + + embeddingJSON, _ := json.Marshal(embedding) + + contentEmbedding := models.ContentEmbedding{ + ContentType: contentType, + ContentID: contentID, + Embedding: string(embeddingJSON), + Model: "text-embedding-ada-002", + Dimensions: len(embedding), + TextContent: text, + UserID: userID, + } + + db.Where("content_type = ? AND content_id = ? AND user_id = ?", contentType, contentID, userID).Delete(&models.ContentEmbedding{}) + db.Create(&contentEmbedding) +} + +func normalizeSemanticContentType(contentType string) string { + switch strings.ToLower(strings.TrimSpace(contentType)) { + case "bookmarks": + return "bookmark" + case "tasks": + return "task" + case "notes": + return "note" + case "files": + return "file" + case "calendar_events": + return "calendar_event" + case "youtube_videos": + return "youtube_video" + case "learning_paths": + return "learning_path" + case "chat_messages": + return "chat_message" + default: + return strings.ToLower(strings.TrimSpace(contentType)) + } +} + +func compactSemanticText(text string, limit int) string { + text = strings.TrimSpace(text) + if len(text) <= limit { + return text + } + if limit < 4 { + return text + } + return strings.TrimSpace(text[:limit-3]) + "..." +} diff --git a/backend/main.go b/backend/main.go index 7d37005..7e60b01 100644 --- a/backend/main.go +++ b/backend/main.go @@ -349,6 +349,35 @@ func main() { chat.DELETE("/sessions/:id", handlers.DeleteSession) } + // Messaging routes (Discord-like user communication) + messages := v1.Group("/messages") + messages.Use(handlers.AuthMiddleware()) + { + messages.GET("/conversations", handlers.GetConversations) + messages.POST("/conversations", handlers.CreateConversation) + messages.GET("/conversations/:id", handlers.GetConversation) + messages.PATCH("/conversations/:id", handlers.UpdateConversation) + messages.POST("/conversations/:id/members", handlers.AddConversationMember) + messages.DELETE("/conversations/:id/members/:userId", handlers.RemoveConversationMember) + messages.GET("/conversations/:id/messages", handlers.GetConversationMessages) + messages.POST("/conversations/:id/messages", handlers.CreateConversationMessage) + messages.PATCH("/messages/:id", handlers.UpdateMessage) + messages.DELETE("/messages/:id", handlers.DeleteMessage) + messages.POST("/messages/:id/reactions", handlers.AddMessageReaction) + messages.DELETE("/messages/:id/reactions/:emoji", handlers.RemoveMessageReaction) + messages.POST("/messages/search", handlers.SearchMessages) + messages.GET("/messages/:id/suggestions", handlers.GetMessageSuggestions) + messages.POST("/messages/:id/suggestions/:suggestionId/accept", handlers.AcceptMessageSuggestion) + messages.POST("/messages/:id/suggestions/:suggestionId/dismiss", handlers.DismissMessageSuggestion) + messages.GET("/ws", handlers.MessagesWebSocket) + + messages.GET("/password-vault/items", handlers.GetPasswordVaultItems) + messages.POST("/password-vault/items", handlers.CreatePasswordVaultItem) + messages.POST("/password-vault/items/:id/share", handlers.SharePasswordVaultItem) + messages.POST("/password-vault/items/:id/reveal", handlers.RevealPasswordVaultItem) + messages.POST("/password-vault/items/:id/unshare", handlers.UnsharePasswordVaultItem) + } + // Member routes (protected) members := v1.Group("/members") members.Use(handlers.AuthMiddleware()) diff --git a/backend/models/messages.go b/backend/models/messages.go new file mode 100644 index 0000000..c8fe651 --- /dev/null +++ b/backend/models/messages.go @@ -0,0 +1,200 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// ConversationType represents the type of a conversation. +type ConversationType string + +const ( + ConversationTypeGlobal ConversationType = "global" + ConversationTypeTeam ConversationType = "team" + ConversationTypeGroup ConversationType = "group" + ConversationTypeDM ConversationType = "dm" + ConversationTypeSelf ConversationType = "self" + ConversationTypePasswordVault ConversationType = "password_vault" +) + +// ConversationMemberRole represents the role of a user in a conversation. +type ConversationMemberRole string + +const ( + ConversationMemberRoleOwner ConversationMemberRole = "owner" + ConversationMemberRoleAdmin ConversationMemberRole = "admin" + ConversationMemberRoleMember ConversationMemberRole = "member" + ConversationMemberRoleViewer ConversationMemberRole = "viewer" +) + +// SuggestionStatus is the lifecycle state of a message suggestion. +type SuggestionStatus string + +const ( + SuggestionStatusPending SuggestionStatus = "pending" + SuggestionStatusAccepted SuggestionStatus = "accepted" + SuggestionStatusDismissed SuggestionStatus = "dismissed" +) + +// Conversation is a user-to-user chat space (global/team/group/dm/self/password). +type Conversation struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + Type ConversationType `json:"type" gorm:"not null;index"` + Name string `json:"name" gorm:"not null"` + Topic string `json:"topic"` + TeamID *uint `json:"team_id,omitempty" gorm:"index"` + Team *Team `json:"team,omitempty" gorm:"foreignKey:TeamID"` + CreatedBy uint `json:"created_by" gorm:"not null;index"` + Creator User `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` + IsDefault bool `json:"is_default" gorm:"default:false;index"` + IsArchived bool `json:"is_archived" gorm:"default:false;index"` + + LastMessageAt *time.Time `json:"last_message_at"` + + Members []ConversationMember `json:"members,omitempty" gorm:"foreignKey:ConversationID"` + Messages []Message `json:"messages,omitempty" gorm:"foreignKey:ConversationID"` +} + +// ConversationMember links users to conversations. +type ConversationMember struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + ConversationID uint `json:"conversation_id" gorm:"not null;index:idx_conv_member,unique"` + UserID uint `json:"user_id" gorm:"not null;index:idx_conv_member,unique"` + Role ConversationMemberRole `json:"role" gorm:"not null;default:member"` + JoinedAt time.Time `json:"joined_at"` + + LastReadMessageID *uint `json:"last_read_message_id,omitempty" gorm:"index"` + LastReadAt *time.Time `json:"last_read_at,omitempty"` + MutedUntil *time.Time `json:"muted_until,omitempty"` + IsHidden bool `json:"is_hidden" gorm:"default:false"` + + Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"` + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` +} + +// Message is a single chat message in a conversation. +type Message struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + ConversationID uint `json:"conversation_id" gorm:"not null;index"` + Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"` + SenderID uint `json:"sender_id" gorm:"not null;index"` + Sender User `json:"sender,omitempty" gorm:"foreignKey:SenderID"` + + Body string `json:"body" gorm:"type:text"` + IsSensitive bool `json:"is_sensitive" gorm:"default:false"` + EditedAt *time.Time `json:"edited_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"` + + MetadataJSON string `json:"metadata_json" gorm:"type:text"` + + Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"foreignKey:MessageID"` + References []MessageReference `json:"references,omitempty" gorm:"foreignKey:MessageID"` + Suggestions []MessageSuggestion `json:"suggestions,omitempty" gorm:"foreignKey:MessageID"` + Reactions []MessageReaction `json:"reactions,omitempty" gorm:"foreignKey:MessageID"` +} + +// MessageAttachment represents file/link-style message attachments. +type MessageAttachment struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + MessageID uint `json:"message_id" gorm:"not null;index"` + Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` + + Kind string `json:"kind" gorm:"not null;index"` // file,image,youtube,github,website,bookmark,task,event,calendar,activity,learning_path,saved_search,voice_note + FileID *uint `json:"file_id,omitempty" gorm:"index"` + URL string `json:"url"` + Title string `json:"title"` + PreviewJSON string `json:"preview_json" gorm:"type:text"` +} + +// MessageReference maps chat messages to Trackeep entities. +type MessageReference struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + MessageID uint `json:"message_id" gorm:"not null;index"` + Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` + + EntityType string `json:"entity_type" gorm:"not null;index"` + EntityID uint `json:"entity_id" gorm:"not null;index"` + DeepLink string `json:"deep_link" gorm:"not null"` +} + +// MessageSuggestion stores non-blocking smart suggestions triggered by message text. +type MessageSuggestion struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + MessageID uint `json:"message_id" gorm:"not null;index"` + Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` + + Type string `json:"type" gorm:"not null;index"` // create_task, create_event, save_bookmark, ... + PayloadJSON string `json:"payload_json" gorm:"type:text"` + Status SuggestionStatus `json:"status" gorm:"not null;default:pending;index"` +} + +// MessageReaction stores emoji reactions. +type MessageReaction struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + MessageID uint `json:"message_id" gorm:"not null;index:idx_message_reaction,unique"` + Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` + UserID uint `json:"user_id" gorm:"not null;index:idx_message_reaction,unique"` + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Emoji string `json:"emoji" gorm:"not null;index:idx_message_reaction,unique"` +} + +// PasswordVaultItem is encrypted secret data owned by a user. +type PasswordVaultItem struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + OwnerUserID uint `json:"owner_user_id" gorm:"not null;index"` + OwnerUser User `json:"owner_user,omitempty" gorm:"foreignKey:OwnerUserID"` + + Label string `json:"label" gorm:"not null"` + EncryptedSecret string `json:"-" gorm:"type:text;not null"` + EncryptedNotes string `json:"-" gorm:"type:text"` + SourceMessageID *uint `json:"source_message_id,omitempty" gorm:"index"` + SourceMessage *Message `json:"source_message,omitempty" gorm:"foreignKey:SourceMessageID"` + CreatedBy uint `json:"created_by" gorm:"not null;index"` + LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` + + Shares []PasswordVaultShare `json:"shares,omitempty" gorm:"foreignKey:VaultItemID"` +} + +// PasswordVaultShare controls explicit sharing of vault items. +type PasswordVaultShare struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + VaultItemID uint `json:"vault_item_id" gorm:"not null;index"` + VaultItem PasswordVaultItem `json:"vault_item,omitempty" gorm:"foreignKey:VaultItemID"` + + SharedByUserID uint `json:"shared_by_user_id" gorm:"not null;index"` + SharedByUser User `json:"shared_by_user,omitempty" gorm:"foreignKey:SharedByUserID"` + TargetConversationID uint `json:"target_conversation_id" gorm:"not null;index"` + TargetConversation Conversation `json:"target_conversation,omitempty" gorm:"foreignKey:TargetConversationID"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AllowReveal bool `json:"allow_reveal" gorm:"default:false"` +} diff --git a/backend/models/models.go b/backend/models/models.go index ca4bbed..41e0ea1 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -113,5 +113,15 @@ func AutoMigrate() { &YouTubeChannelCache{}, // Video bookmark models &VideoBookmark{}, + // Messaging models + &Conversation{}, + &ConversationMember{}, + &Message{}, + &MessageAttachment{}, + &MessageReference{}, + &MessageSuggestion{}, + &MessageReaction{}, + &PasswordVaultItem{}, + &PasswordVaultShare{}, ) } diff --git a/backend/services/message_detector.go b/backend/services/message_detector.go new file mode 100644 index 0000000..1f93edd --- /dev/null +++ b/backend/services/message_detector.go @@ -0,0 +1,139 @@ +package services + +import ( + "net/url" + "regexp" + "strings" +) + +// DetectedSuggestion represents a suggestion detected from message text. +type DetectedSuggestion struct { + Type string `json:"type"` + Payload map[string]interface{} `json:"payload"` +} + +// DetectedAttachment is an inferred attachment from message content. +type DetectedAttachment struct { + Kind string `json:"kind"` + URL string `json:"url"` + Title string `json:"title"` + PreviewMap map[string]interface{} `json:"preview_map"` +} + +var ( + urlRegex = regexp.MustCompile(`https?://[^\s]+`) + passwordRegex = regexp.MustCompile(`(?i)(password|pass:|pwd|api[_-]?key|access[_-]?token|secret|bearer\s+[a-z0-9\-_\.]+)`) + taskIntentRegex = regexp.MustCompile(`(?i)(todo|to do|task|need to|should|must|remember to|follow up)`) + eventIntentRegex = regexp.MustCompile(`(?i)(meeting|calendar|event|schedule|tomorrow|next week|deadline|at [0-9]{1,2}(:[0-9]{2})?\s?(am|pm)?)`) + searchIntentRegex = regexp.MustCompile(`(?i)(search for|track query|alert me for|watch for|monitor query)`) +) + +// DetectMessageContent inspects a message and returns suggestions, inferred attachments, +// and whether the message appears sensitive. +func DetectMessageContent(body string) ([]DetectedSuggestion, []DetectedAttachment, bool) { + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return nil, nil, false + } + + suggestions := make([]DetectedSuggestion, 0, 8) + attachments := make([]DetectedAttachment, 0, 8) + seenSuggestion := map[string]bool{} + + // URL and service detections + for _, rawURL := range urlRegex.FindAllString(trimmed, -1) { + u, err := url.Parse(rawURL) + if err != nil { + continue + } + host := strings.ToLower(u.Host) + kind := "website" + sType := "save_bookmark" + title := rawURL + + switch { + case strings.Contains(host, "youtube.com") || strings.Contains(host, "youtu.be"): + kind = "youtube" + sType = "save_youtube" + case strings.Contains(host, "github.com"): + kind = "github" + sType = "link_github" + } + + attachments = append(attachments, DetectedAttachment{ + Kind: kind, + URL: rawURL, + Title: title, + PreviewMap: map[string]interface{}{ + "host": host, + }, + }) + + key := sType + ":" + rawURL + if !seenSuggestion[key] { + seenSuggestion[key] = true + suggestions = append(suggestions, DetectedSuggestion{ + Type: sType, + Payload: map[string]interface{}{ + "url": rawURL, + "title": title, + }, + }) + } + } + + if taskIntentRegex.MatchString(trimmed) { + suggestions = append(suggestions, DetectedSuggestion{ + Type: "create_task", + Payload: map[string]interface{}{ + "title": buildCompactTitle(trimmed, 80), + "from_text": trimmed, + }, + }) + } + + if eventIntentRegex.MatchString(trimmed) { + suggestions = append(suggestions, DetectedSuggestion{ + Type: "create_event", + Payload: map[string]interface{}{ + "title": buildCompactTitle(trimmed, 80), + "from_text": trimmed, + }, + }) + } + + if searchIntentRegex.MatchString(trimmed) { + suggestions = append(suggestions, DetectedSuggestion{ + Type: "save_search", + Payload: map[string]interface{}{ + "query": trimmed, + }, + }) + } + + isSensitive := passwordRegex.MatchString(trimmed) + if isSensitive { + suggestions = append(suggestions, DetectedSuggestion{ + Type: "password_warning", + Payload: map[string]interface{}{ + "message": "Sensitive data detected. We recommend a dedicated password manager like Proton Pass (not affiliated).", + }, + }) + suggestions = append(suggestions, DetectedSuggestion{ + Type: "move_to_password_vault", + Payload: map[string]interface{}{ + "message": "Move this message to your encrypted password vault.", + }, + }) + } + + return suggestions, attachments, isSensitive +} + +func buildCompactTitle(input string, limit int) string { + s := strings.TrimSpace(input) + if len(s) <= limit { + return s + } + return strings.TrimSpace(s[:limit-3]) + "..." +} diff --git a/backend/services/message_detector_test.go b/backend/services/message_detector_test.go new file mode 100644 index 0000000..5474eaf --- /dev/null +++ b/backend/services/message_detector_test.go @@ -0,0 +1,83 @@ +package services + +import "testing" + +func TestDetectMessageContent_URLsAndSuggestions(t *testing.T) { + body := "Check this out https://github.com/trackeep/backend and video https://youtu.be/dQw4w9WgXcQ" + + suggestions, attachments, isSensitive := DetectMessageContent(body) + if isSensitive { + t.Fatalf("expected non-sensitive message") + } + + if len(attachments) < 2 { + t.Fatalf("expected at least 2 attachments, got %d", len(attachments)) + } + + hasGitHub := false + hasYouTube := false + for _, s := range suggestions { + if s.Type == "link_github" { + hasGitHub = true + } + if s.Type == "save_youtube" { + hasYouTube = true + } + } + + if !hasGitHub { + t.Fatalf("expected link_github suggestion") + } + if !hasYouTube { + t.Fatalf("expected save_youtube suggestion") + } +} + +func TestDetectMessageContent_TaskAndEventIntents(t *testing.T) { + body := "TODO: schedule meeting tomorrow at 10am to review the release plan" + suggestions, _, _ := DetectMessageContent(body) + + hasTask := false + hasEvent := false + for _, s := range suggestions { + if s.Type == "create_task" { + hasTask = true + } + if s.Type == "create_event" { + hasEvent = true + } + } + + if !hasTask { + t.Fatalf("expected create_task suggestion") + } + if !hasEvent { + t.Fatalf("expected create_event suggestion") + } +} + +func TestDetectMessageContent_PasswordWarning(t *testing.T) { + body := "password: SuperSecret123!" + suggestions, _, isSensitive := DetectMessageContent(body) + if !isSensitive { + t.Fatalf("expected sensitive message") + } + + hasWarning := false + hasVaultMove := false + for _, s := range suggestions { + if s.Type == "password_warning" { + hasWarning = true + } + if s.Type == "move_to_password_vault" { + hasVaultMove = true + } + } + + if !hasWarning { + t.Fatalf("expected password_warning suggestion") + } + if !hasVaultMove { + t.Fatalf("expected move_to_password_vault suggestion") + } +} diff --git a/backend/services/messages_realtime.go b/backend/services/messages_realtime.go new file mode 100644 index 0000000..c85f65c --- /dev/null +++ b/backend/services/messages_realtime.go @@ -0,0 +1,172 @@ +package services + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// WsEvent is a realtime event payload emitted by the messaging hub. +type WsEvent struct { + Type string `json:"type"` + ConversationID uint `json:"conversation_id,omitempty"` + Data interface{} `json:"data,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// MessagesWSClient represents one websocket connection. +type MessagesWSClient struct { + UserID uint + Conn *websocket.Conn + Send chan []byte + Conversations map[uint]struct{} +} + +// MessagesHub coordinates room-based websocket fanout. +type MessagesHub struct { + mu sync.RWMutex + conversationClients map[uint]map[*MessagesWSClient]struct{} + clientConversations map[*MessagesWSClient]map[uint]struct{} +} + +var defaultMessagesHub = NewMessagesHub() + +// GetMessagesHub returns the shared messaging websocket hub. +func GetMessagesHub() *MessagesHub { + return defaultMessagesHub +} + +// NewMessagesHub creates a new messages websocket hub. +func NewMessagesHub() *MessagesHub { + return &MessagesHub{ + conversationClients: make(map[uint]map[*MessagesWSClient]struct{}), + clientConversations: make(map[*MessagesWSClient]map[uint]struct{}), + } +} + +// NewWSClient creates a ws client wrapper. +func NewWSClient(userID uint, conn *websocket.Conn) *MessagesWSClient { + return &MessagesWSClient{ + UserID: userID, + Conn: conn, + Send: make(chan []byte, 128), + Conversations: make(map[uint]struct{}), + } +} + +// AddClientToConversation subscribes a client to a conversation room. +func (h *MessagesHub) AddClientToConversation(client *MessagesWSClient, conversationID uint) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, exists := h.conversationClients[conversationID]; !exists { + h.conversationClients[conversationID] = make(map[*MessagesWSClient]struct{}) + } + h.conversationClients[conversationID][client] = struct{}{} + + if _, exists := h.clientConversations[client]; !exists { + h.clientConversations[client] = make(map[uint]struct{}) + } + h.clientConversations[client][conversationID] = struct{}{} + client.Conversations[conversationID] = struct{}{} +} + +// RemoveClientFromConversation unsubscribes a client from one room. +func (h *MessagesHub) RemoveClientFromConversation(client *MessagesWSClient, conversationID uint) { + h.mu.Lock() + defer h.mu.Unlock() + + if clients, exists := h.conversationClients[conversationID]; exists { + delete(clients, client) + if len(clients) == 0 { + delete(h.conversationClients, conversationID) + } + } + + if convs, exists := h.clientConversations[client]; exists { + delete(convs, conversationID) + if len(convs) == 0 { + delete(h.clientConversations, client) + } + } + delete(client.Conversations, conversationID) +} + +// RemoveClient fully unregisters a client from all rooms. +func (h *MessagesHub) RemoveClient(client *MessagesWSClient) { + h.mu.Lock() + defer h.mu.Unlock() + + if convs, exists := h.clientConversations[client]; exists { + for convID := range convs { + if clients, ok := h.conversationClients[convID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.conversationClients, convID) + } + } + } + delete(h.clientConversations, client) + } + + close(client.Send) +} + +// Broadcast emits an event to all clients in one conversation room. +func (h *MessagesHub) Broadcast(conversationID uint, eventType string, data interface{}) { + event := WsEvent{ + Type: eventType, + ConversationID: conversationID, + Data: data, + Timestamp: time.Now(), + } + + raw, err := json.Marshal(event) + if err != nil { + return + } + + h.mu.RLock() + clients := h.conversationClients[conversationID] + h.mu.RUnlock() + + for client := range clients { + select { + case client.Send <- raw: + default: + go h.RemoveClient(client) + } + } +} + +// SendToUser emits an event to one user in a conversation room. +func (h *MessagesHub) SendToUser(conversationID, userID uint, eventType string, data interface{}) { + event := WsEvent{ + Type: eventType, + ConversationID: conversationID, + Data: data, + Timestamp: time.Now(), + } + + raw, err := json.Marshal(event) + if err != nil { + return + } + + h.mu.RLock() + clients := h.conversationClients[conversationID] + h.mu.RUnlock() + + for client := range clients { + if client.UserID != userID { + continue + } + select { + case client.Send <- raw: + default: + go h.RemoveClient(client) + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db0b547..060dfa6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { AuthCallback } from '@/pages/AuthCallback' import { AuthProvider } from '@/lib/auth' import { Search } from '@/pages/Search' import { Analytics } from '@/pages/Analytics' +import { Messages } from '@/pages/Messages' import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode' import { onMount } from 'solid-js' @@ -168,6 +169,13 @@ function App() { )} /> + ( + + + + + + )} /> ( diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index b40d6ea..d08cfa7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ import { IconBrandGithub, IconClock, IconCalendar, + IconMessageCircle, IconLogout, IconBuilding, IconPlus, @@ -33,6 +34,7 @@ const navigation = [ { name: 'Calendar', href: '/app/calendar', icon: IconCalendar }, { name: 'Files', href: '/app/files', icon: IconFolder }, { name: 'Notes', href: '/app/notes', icon: IconNotebook }, + { name: 'Messages', href: '/app/messages', icon: IconMessageCircle }, { name: 'YouTube', href: '/app/youtube', icon: IconVideo }, { name: 'Members', href: '/app/members', icon: IconUsers }, { name: 'Learning', href: '/app/learning-paths', icon: IconSchool }, diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts new file mode 100644 index 0000000..3ec0239 --- /dev/null +++ b/frontend/src/lib/messages.ts @@ -0,0 +1,301 @@ +export type ConversationType = 'global' | 'team' | 'group' | 'dm' | 'self' | 'password_vault'; + +export interface UserLite { + id: number; + username: string; + full_name?: string; + avatar_url?: string; +} + +export interface Conversation { + id: number; + type: ConversationType; + name: string; + topic?: string; + team_id?: number | null; + created_by: number; + is_default: boolean; + is_archived: boolean; + last_message_at?: string; + created_at: string; + updated_at: string; +} + +export interface ConversationListItem { + conversation: Conversation; + role: string; + unread_count: number; + last_message?: Message; +} + +export interface ConversationMember { + id: number; + conversation_id: number; + user_id: number; + role: 'owner' | 'admin' | 'member' | 'viewer' | string; + joined_at?: string; + last_read_message_id?: number | null; + last_read_at?: string | null; + muted_until?: string | null; + is_hidden?: boolean; + user?: UserLite; +} + +export interface MessageAttachment { + id: number; + message_id: number; + kind: string; + file_id?: number | null; + url?: string; + title?: string; + preview_json?: string; +} + +export interface MessageReference { + id: number; + message_id: number; + entity_type: string; + entity_id: number; + deep_link: string; +} + +export interface MessageSuggestion { + id: number; + message_id: number; + type: string; + payload_json: string; + status: 'pending' | 'accepted' | 'dismissed'; + created_at: string; + updated_at: string; +} + +export interface MessageReaction { + id: number; + message_id: number; + user_id: number; + emoji: string; +} + +export interface Message { + id: number; + conversation_id: number; + sender_id: number; + sender?: UserLite; + body: string; + is_sensitive: boolean; + edited_at?: string | null; + deleted_at?: string | null; + metadata_json?: string; + created_at: string; + updated_at: string; + attachments?: MessageAttachment[]; + references?: MessageReference[]; + suggestions?: MessageSuggestion[]; + reactions?: MessageReaction[]; +} + +export interface VaultItem { + id: number; + label: string; + owner_user_id: number; + source_message_id?: number | null; + last_accessed_at?: string | null; + shared: boolean; + allow_reveal: boolean; + expires_at?: string | null; + target_conversation_id?: number | null; +} + +export interface WsEvent { + type: string; + conversation_id?: number; + data?: any; + timestamp?: string; +} + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'; + +function getToken() { + return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''; +} + +async function apiRequest(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + const res = await fetch(`${API_BASE_URL}/api/v1/messages${path}`, { + ...options, + headers: { + ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.error || `Request failed (${res.status})`); + } + return res.json(); +} + +export const messagesApi = { + listConversations: () => apiRequest<{ conversations: ConversationListItem[] }>('/conversations'), + createConversation: (payload: any) => apiRequest<{ conversation: Conversation }>('/conversations', { + method: 'POST', + body: JSON.stringify(payload), + }), + getConversation: (id: number) => + apiRequest<{ conversation: Conversation; membership: ConversationMember; members: ConversationMember[] }>( + `/conversations/${id}` + ), + getMessages: (conversationId: number, cursor?: number, limit: number = 50) => + apiRequest<{ messages: Message[]; next_cursor?: number }>( + `/conversations/${conversationId}/messages?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}` + ), + sendMessage: (conversationId: number, payload: any) => apiRequest<{ message: Message; warning?: string }>( + `/conversations/${conversationId}/messages`, + { + method: 'POST', + body: JSON.stringify(payload), + } + ), + updateMessage: (id: number, body: string) => + apiRequest<{ message: Message }>(`/messages/${id}`, { method: 'PATCH', body: JSON.stringify({ body }) }), + deleteMessage: (id: number) => apiRequest<{ message: string }>(`/messages/${id}`, { method: 'DELETE' }), + addReaction: (id: number, emoji: string) => + apiRequest<{ reaction: MessageReaction }>(`/messages/${id}/reactions`, { + method: 'POST', + body: JSON.stringify({ emoji }), + }), + removeReaction: (id: number, emoji: string) => + apiRequest<{ message: string }>(`/messages/${id}/reactions/${encodeURIComponent(emoji)}`, { method: 'DELETE' }), + searchMessages: (payload: any) => + apiRequest<{ results: Message[]; total: number; limit: number; offset: number }>( + '/messages/search', + { + method: 'POST', + body: JSON.stringify(payload), + } + ), + getSuggestions: (messageId: number) => apiRequest<{ suggestions: MessageSuggestion[] }>(`/messages/${messageId}/suggestions`), + acceptSuggestion: (messageId: number, suggestionId: number, payload: any = {}) => + apiRequest(`/messages/${messageId}/suggestions/${suggestionId}/accept`, { + method: 'POST', + body: JSON.stringify(payload), + }), + dismissSuggestion: (messageId: number, suggestionId: number) => + apiRequest(`/messages/${messageId}/suggestions/${suggestionId}/dismiss`, { + method: 'POST', + body: JSON.stringify({}), + }), + listVaultItems: () => apiRequest<{ items: VaultItem[] }>('/password-vault/items'), + createVaultItem: (payload: any) => apiRequest('/password-vault/items', { + method: 'POST', + body: JSON.stringify(payload), + }), + shareVaultItem: (id: number, payload: any) => + apiRequest(`/password-vault/items/${id}/share`, { + method: 'POST', + body: JSON.stringify(payload), + }), + revealVaultItem: (id: number) => + apiRequest<{ id: number; label: string; secret: string; notes: string; warning?: string }>( + `/password-vault/items/${id}/reveal`, + { method: 'POST', body: JSON.stringify({}) } + ), + unshareVaultItem: (id: number, payload: any) => + apiRequest(`/password-vault/items/${id}/unshare`, { + method: 'POST', + body: JSON.stringify(payload), + }), +}; + +export async function uploadChatFile(file: File): Promise<{ id: number; original_name: string; mime_type: string }> { + const token = getToken(); + const formData = new FormData(); + formData.append('file', file); + formData.append('description', 'Uploaded from chat'); + + const res = await fetch(`${API_BASE_URL}/api/v1/files/upload`, { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: formData, + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.error || `Upload failed (${res.status})`); + } + return res.json(); +} + +export class MessagesRealtimeClient { + private ws: WebSocket | null = null; + private reconnectTimer: number | null = null; + private shouldReconnect = true; + private onEvent: (event: WsEvent) => void; + private onStatus?: (status: 'connected' | 'disconnected' | 'error') => void; + + constructor( + onEvent: (event: WsEvent) => void, + onStatus?: (status: 'connected' | 'disconnected' | 'error') => void + ) { + this.onEvent = onEvent; + this.onStatus = onStatus; + } + + connect() { + this.cleanupReconnect(); + const token = getToken(); + if (!token) return; + + const wsBase = API_BASE_URL.replace(/^http/, 'ws'); + this.ws = new WebSocket(`${wsBase}/api/v1/messages/ws?token=${encodeURIComponent(token)}`); + + this.ws.onopen = () => { + this.onStatus?.('connected'); + }; + + this.ws.onmessage = (evt) => { + try { + const parsed = JSON.parse(evt.data) as WsEvent; + this.onEvent(parsed); + } catch { + // ignore invalid payloads + } + }; + + this.ws.onerror = () => { + this.onStatus?.('error'); + }; + + this.ws.onclose = () => { + this.onStatus?.('disconnected'); + this.ws = null; + if (this.shouldReconnect) { + this.reconnectTimer = window.setTimeout(() => this.connect(), 2000); + } + }; + } + + send(payload: any) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify(payload)); + } + + disconnect() { + this.shouldReconnect = false; + this.cleanupReconnect(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private cleanupReconnect() { + if (this.reconnectTimer) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } +} diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx new file mode 100644 index 0000000..2e786ce --- /dev/null +++ b/frontend/src/pages/Messages.tsx @@ -0,0 +1,2011 @@ +import { createSignal, For, Show, onCleanup, onMount } from 'solid-js'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Card } from '@/components/ui/Card'; +import { toast } from '@/components/ui/Toast'; +import { + MessagesRealtimeClient, + messagesApi, + uploadChatFile, +} from '@/lib/messages'; +import type { + ConversationListItem, + ConversationMember, + Message, + MessageSuggestion, + VaultItem, +} from '@/lib/messages'; +import { + IconBell, + IconBellOff, + IconLock, + IconMessageCircle, + IconPlus, + IconSearch, + IconSend, + IconUsers, + IconX, +} from '@tabler/icons-solidjs'; + +interface MemberOption { + id: number; + username: string; + name: string; +} + +const ATTACHMENT_KIND_OPTIONS = [ + 'file', + 'image', + 'voice_note', + 'youtube', + 'github', + 'website', + 'bookmark', + 'task', + 'event', + 'calendar', + 'activity', + 'learning_path', + 'saved_search', +]; + +const REFERENCE_TYPE_OPTIONS = [ + 'task', + 'bookmark', + 'calendar_event', + 'youtube_video', + 'learning_path', + 'saved_search', + 'github', + 'password_vault_item', +]; + +type TriStateFilter = 'any' | 'yes' | 'no'; +type CallStatus = 'idle' | 'starting' | 'calling' | 'in_call' | 'error'; + +export const Messages = () => { + const [conversations, setConversations] = createSignal([]); + const [messages, setMessages] = createSignal([]); + const [selectedConversationId, setSelectedConversationId] = createSignal(null); + const [loadingConversations, setLoadingConversations] = createSignal(false); + const [loadingMessages, setLoadingMessages] = createSignal(false); + const [inputText, setInputText] = createSignal(''); + const [selectedFiles, setSelectedFiles] = createSignal([]); + const [sendingMessage, setSendingMessage] = createSignal(false); + const [wsStatus, setWsStatus] = createSignal<'connected' | 'disconnected' | 'error'>('disconnected'); + const [searchOpen, setSearchOpen] = createSignal(false); + const [searchQuery, setSearchQuery] = createSignal(''); + const [searchResults, setSearchResults] = createSignal([]); + const [searching, setSearching] = createSignal(false); + const [showVault, setShowVault] = createSignal(false); + const [vaultItems, setVaultItems] = createSignal([]); + const [revealedSecrets, setRevealedSecrets] = createSignal>({}); + const [shareTargets, setShareTargets] = createSignal>({}); + const [members, setMembers] = createSignal([]); + const [conversationMembers, setConversationMembers] = createSignal([]); + const [typingByConversation, setTypingByConversation] = createSignal>>({}); + const [newConversationType, setNewConversationType] = createSignal<'dm' | 'group' | 'team'>('dm'); + const [newConversationName, setNewConversationName] = createSignal(''); + const [newConversationTopic, setNewConversationTopic] = createSignal(''); + const [targetUserId, setTargetUserId] = createSignal(''); + const [groupUserIds, setGroupUserIds] = createSignal(''); + const [teamId, setTeamId] = createSignal(''); + const [showCreateConversation, setShowCreateConversation] = createSignal(false); + const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = createSignal( + localStorage.getItem('messages_browser_notifications') === 'true' + ); + const [searchConversationIds, setSearchConversationIds] = createSignal([]); + const [searchSenderId, setSearchSenderId] = createSignal(''); + const [searchDateFrom, setSearchDateFrom] = createSignal(''); + const [searchDateTo, setSearchDateTo] = createSignal(''); + const [searchAttachmentKinds, setSearchAttachmentKinds] = createSignal([]); + const [searchReferenceTypes, setSearchReferenceTypes] = createSignal([]); + const [searchHasLinks, setSearchHasLinks] = createSignal('any'); + const [searchHasAttachments, setSearchHasAttachments] = createSignal('any'); + const [searchHasSuggestions, setSearchHasSuggestions] = createSignal('any'); + const [searchMentionOnly, setSearchMentionOnly] = createSignal(false); + const [voiceTranscriptEnabled, setVoiceTranscriptEnabled] = createSignal( + localStorage.getItem('messages_voice_transcript') !== 'false' + ); + const [isRecordingVoice, setIsRecordingVoice] = createSignal(false); + const [voiceRecordingMs, setVoiceRecordingMs] = createSignal(0); + const [voiceTranscriptPreview, setVoiceTranscriptPreview] = createSignal(''); + const [callStatus, setCallStatus] = createSignal('idle'); + const [callMuted, setCallMuted] = createSignal(false); + const [activeCallConversationId, setActiveCallConversationId] = createSignal(null); + const [activeCallPeerIds, setActiveCallPeerIds] = createSignal([]); + const [callTranscriptEnabled, setCallTranscriptEnabled] = createSignal( + localStorage.getItem('messages_call_transcript') !== 'false' + ); + const [callTranscriptPreview, setCallTranscriptPreview] = createSignal(''); + + const getCurrentUserId = () => { + const raw = localStorage.getItem('trackeep_user') || localStorage.getItem('user'); + if (!raw) return 0; + try { + const parsed = JSON.parse(raw); + return parsed.id || 0; + } catch { + return 0; + } + }; + + const currentUserId = () => getCurrentUserId(); + + let realtime: MessagesRealtimeClient | null = null; + let pollInterval: number | null = null; + let typingStopTimer: number | null = null; + let typingCleanupTimer: number | null = null; + let lastTypingStartedAt = 0; + let voiceRecorder: MediaRecorder | null = null; + let voiceRecordingStream: MediaStream | null = null; + let voiceRecordingChunks: BlobPart[] = []; + let voiceRecordingTimer: number | null = null; + let discardVoiceRecording = false; + let voiceRecognition: any = null; + let voiceFinalTranscript = ''; + let voiceInterimTranscript = ''; + let callLocalStream: MediaStream | null = null; + let callPeers = new Map(); + let remoteAudioElements = new Map(); + let callRecognition: any = null; + let callFinalTranscript = ''; + let callInterimTranscript = ''; + + const sortedConversations = () => + [...conversations()].sort((a, b) => { + const aDate = a.conversation.last_message_at || a.conversation.updated_at; + const bDate = b.conversation.last_message_at || b.conversation.updated_at; + return new Date(bDate).getTime() - new Date(aDate).getTime(); + }); + + const activeConversation = () => + conversations().find((item) => item.conversation.id === selectedConversationId()) || null; + + const getSpeechRecognitionCtor = () => { + if (typeof window === 'undefined') return null; + const w = window as any; + return w.SpeechRecognition || w.webkitSpeechRecognition || null; + }; + + const isCallActive = () => + callStatus() === 'starting' || callStatus() === 'calling' || callStatus() === 'in_call'; + + const triStateToBool = (value: TriStateFilter): boolean | undefined => { + if (value === 'yes') return true; + if (value === 'no') return false; + return undefined; + }; + + const toggleNumberFilter = (current: number[], value: number) => { + if (current.includes(value)) { + return current.filter((v) => v !== value); + } + return [...current, value]; + }; + + const toggleStringFilter = (current: string[], value: string) => { + if (current.includes(value)) { + return current.filter((v) => v !== value); + } + return [...current, value]; + }; + + const toISOOrUndefined = (value: string): string | undefined => { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) return undefined; + return parsed.toISOString(); + }; + + const clearSearchFilters = () => { + setSearchConversationIds([]); + setSearchSenderId(''); + setSearchDateFrom(''); + setSearchDateTo(''); + setSearchAttachmentKinds([]); + setSearchReferenceTypes([]); + setSearchHasLinks('any'); + setSearchHasAttachments('any'); + setSearchHasSuggestions('any'); + setSearchMentionOnly(false); + }; + + const activeTypingUserNames = () => { + const conversationID = selectedConversationId(); + if (!conversationID) return [] as string[]; + + const usersMap = typingByConversation()[conversationID] || {}; + const names = Object.keys(usersMap) + .map((id) => Number(id)) + .filter((id) => id !== currentUserId()) + .map((id) => { + const member = conversationMembers().find((m) => m.user_id === id); + return member?.user?.full_name || member?.user?.username || `User ${id}`; + }); + + return names; + }; + + const requestNotificationPermission = async () => { + if (!('Notification' in window)) { + toast.warning('Browser notifications unavailable', 'This browser does not support notifications.'); + return; + } + if (Notification.permission === 'granted') { + setBrowserNotificationsEnabled(true); + localStorage.setItem('messages_browser_notifications', 'true'); + return; + } + const permission = await Notification.requestPermission(); + const enabled = permission === 'granted'; + setBrowserNotificationsEnabled(enabled); + localStorage.setItem('messages_browser_notifications', enabled ? 'true' : 'false'); + }; + + const maybeNotify = (title: string, body: string) => { + if (!browserNotificationsEnabled()) return; + if (!('Notification' in window)) return; + if (Notification.permission !== 'granted') return; + new Notification(title, { body }); + }; + + const sendTypingEvent = (type: 'typing.started' | 'typing.stopped', explicitConversationId?: number) => { + const conversationID = explicitConversationId || selectedConversationId(); + if (!conversationID) return; + if (!realtime || wsStatus() !== 'connected') return; + realtime.send({ + type, + conversation_id: conversationID, + }); + }; + + const markTypingStopped = (explicitConversationId?: number) => { + sendTypingEvent('typing.stopped', explicitConversationId); + if (typingStopTimer) { + window.clearTimeout(typingStopTimer); + typingStopTimer = null; + } + }; + + const markTypingStarted = () => { + const conversationID = selectedConversationId(); + if (!conversationID) return; + + const now = Date.now(); + if (now-lastTypingStartedAt > 1200) { + sendTypingEvent('typing.started', conversationID); + lastTypingStartedAt = now; + } + + if (typingStopTimer) { + window.clearTimeout(typingStopTimer); + } + typingStopTimer = window.setTimeout(() => { + markTypingStopped(conversationID); + }, 2200); + }; + + const setVoiceTranscriptPreference = (enabled: boolean) => { + setVoiceTranscriptEnabled(enabled); + localStorage.setItem('messages_voice_transcript', enabled ? 'true' : 'false'); + }; + + const setCallTranscriptPreference = (enabled: boolean) => { + setCallTranscriptEnabled(enabled); + localStorage.setItem('messages_call_transcript', enabled ? 'true' : 'false'); + }; + + const formatMs = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + }; + + const stopVoiceRecognition = () => { + if (voiceRecognition) { + try { + voiceRecognition.onresult = null; + voiceRecognition.onerror = null; + voiceRecognition.onend = null; + voiceRecognition.stop(); + } catch { + // ignore + } + voiceRecognition = null; + } + }; + + const startVoiceRecognition = () => { + if (!voiceTranscriptEnabled()) return; + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) return; + + voiceFinalTranscript = ''; + voiceInterimTranscript = ''; + setVoiceTranscriptPreview(''); + + const recognition = new Ctor(); + recognition.lang = navigator.language || 'en-US'; + recognition.continuous = true; + recognition.interimResults = true; + recognition.onresult = (event: any) => { + for (let i = event.resultIndex; i < event.results.length; i += 1) { + const transcript = event.results[i][0]?.transcript || ''; + if (event.results[i].isFinal) { + voiceFinalTranscript += `${transcript} `; + } else { + voiceInterimTranscript = transcript; + } + } + const merged = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim(); + setVoiceTranscriptPreview(merged); + }; + recognition.onerror = () => { + // Keep recording even if browser transcript fails. + }; + recognition.onend = () => { + voiceRecognition = null; + }; + try { + recognition.start(); + voiceRecognition = recognition; + } catch { + voiceRecognition = null; + } + }; + + const stopCallRecognition = () => { + if (callRecognition) { + try { + callRecognition.onresult = null; + callRecognition.onerror = null; + callRecognition.onend = null; + callRecognition.stop(); + } catch { + // ignore + } + callRecognition = null; + } + setCallTranscriptPreview(''); + callFinalTranscript = ''; + callInterimTranscript = ''; + }; + + const startCallRecognition = () => { + if (!callTranscriptEnabled()) return; + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) return; + + callFinalTranscript = ''; + callInterimTranscript = ''; + setCallTranscriptPreview(''); + + const recognition = new Ctor(); + recognition.lang = navigator.language || 'en-US'; + recognition.continuous = true; + recognition.interimResults = true; + recognition.onresult = (event: any) => { + for (let i = event.resultIndex; i < event.results.length; i += 1) { + const transcript = event.results[i][0]?.transcript || ''; + if (event.results[i].isFinal) { + callFinalTranscript += `${transcript} `; + } else { + callInterimTranscript = transcript; + } + } + const merged = `${callFinalTranscript} ${callInterimTranscript}`.trim(); + setCallTranscriptPreview(merged); + }; + recognition.onerror = () => { + // keep call running when transcript fails + }; + recognition.onend = () => { + callRecognition = null; + }; + try { + recognition.start(); + callRecognition = recognition; + } catch { + callRecognition = null; + } + }; + + const postMessage = async (body: string, attachments: any[], explicitConversationId?: number) => { + const conversationID = explicitConversationId || selectedConversationId(); + if (!conversationID) return; + const trimmedBody = body.trim(); + if (!trimmedBody && attachments.length === 0) return; + + markTypingStopped(); + setSendingMessage(true); + try { + const response = await messagesApi.sendMessage(conversationID, { + body: trimmedBody, + attachments, + }); + + const created = response.message; + setMessages((prev) => (prev.some((m) => m.id === created.id) ? prev : [...prev, created])); + + if (response.warning) { + toast.warning('Sensitive Content Warning', response.warning); + } + + loadConversations(); + } catch (error) { + throw error; + } finally { + setSendingMessage(false); + } + }; + + const startVoiceRecording = async () => { + const conversationID = selectedConversationId(); + if (!conversationID) { + toast.warning('Conversation required', 'Select a conversation before recording.'); + return; + } + if (isRecordingVoice()) return; + if (!navigator.mediaDevices?.getUserMedia) { + toast.error('Microphone unavailable', 'This browser does not support microphone capture.'); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + voiceRecordingStream = stream; + voiceRecorder = recorder; + voiceRecordingChunks = []; + setVoiceRecordingMs(0); + setIsRecordingVoice(true); + discardVoiceRecording = false; + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + voiceRecordingChunks.push(event.data); + } + }; + + recorder.onstop = async () => { + try { + if (discardVoiceRecording) { + return; + } + const blob = new Blob(voiceRecordingChunks, { type: recorder.mimeType || 'audio/webm' }); + if (blob.size === 0) { + toast.warning('Empty recording', 'No audio was captured.'); + return; + } + + const extension = recorder.mimeType.includes('ogg') ? 'ogg' : recorder.mimeType.includes('mp4') ? 'm4a' : 'webm'; + const file = new File([blob], `voice-note-${Date.now()}.${extension}`, { type: recorder.mimeType || 'audio/webm' }); + const uploaded = await uploadChatFile(file); + const attachments = [{ + kind: 'voice_note', + file_id: uploaded.id, + title: uploaded.original_name || 'Voice note', + url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`, + }]; + + const transcript = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim(); + const transcriptBody = transcript ? `Transcript (local): ${transcript}` : ''; + const composedBody = [inputText().trim(), transcriptBody].filter(Boolean).join('\n\n'); + + await postMessage(composedBody, attachments, conversationID); + setInputText(''); + if (transcriptBody) { + toast.success('Voice note sent', 'Local transcript attached.'); + } else { + toast.success('Voice note sent'); + } + } catch (error) { + toast.error('Failed to send voice note', error instanceof Error ? error.message : 'Unknown error'); + } finally { + stopVoiceRecognition(); + voiceRecordingChunks = []; + setVoiceTranscriptPreview(''); + voiceFinalTranscript = ''; + voiceInterimTranscript = ''; + } + }; + + recorder.start(250); + startVoiceRecognition(); + voiceRecordingTimer = window.setInterval(() => { + setVoiceRecordingMs((prev) => prev + 250); + }, 250); + } catch (error) { + setIsRecordingVoice(false); + toast.error('Microphone access failed', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const stopVoiceRecording = () => { + if (!isRecordingVoice()) return; + setIsRecordingVoice(false); + if (voiceRecordingTimer) { + window.clearInterval(voiceRecordingTimer); + voiceRecordingTimer = null; + } + stopVoiceRecognition(); + if (voiceRecorder && voiceRecorder.state !== 'inactive') { + try { + voiceRecorder.stop(); + } catch { + // ignore + } + } + if (voiceRecordingStream) { + voiceRecordingStream.getTracks().forEach((track) => track.stop()); + voiceRecordingStream = null; + } + voiceRecorder = null; + }; + + const ensureCallLocalStream = async () => { + if (callLocalStream) return callLocalStream; + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Microphone capture is not supported in this browser'); + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + callLocalStream = stream; + return stream; + }; + + const attachRemoteAudio = (userID: number, stream: MediaStream) => { + let element = remoteAudioElements.get(userID); + if (!element) { + element = document.createElement('audio'); + element.autoplay = true; + element.setAttribute('playsinline', 'true'); + element.style.display = 'none'; + document.body.appendChild(element); + remoteAudioElements.set(userID, element); + } + element.srcObject = stream; + setActiveCallPeerIds((prev) => (prev.includes(userID) ? prev : [...prev, userID])); + }; + + const removeRemoteAudio = (userID: number) => { + const element = remoteAudioElements.get(userID); + if (element) { + element.srcObject = null; + element.remove(); + remoteAudioElements.delete(userID); + } + setActiveCallPeerIds((prev) => prev.filter((id) => id !== userID)); + }; + + const closePeerConnection = (userID: number) => { + const pc = callPeers.get(userID); + if (pc) { + try { + pc.onicecandidate = null; + pc.ontrack = null; + pc.close(); + } catch { + // ignore + } + callPeers.delete(userID); + } + removeRemoteAudio(userID); + }; + + const buildPeerConnection = (conversationID: number, peerUserID: number) => { + let pc = callPeers.get(peerUserID); + if (pc) return pc; + + pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + callPeers.set(peerUserID, pc); + + if (callLocalStream) { + callLocalStream.getTracks().forEach((track) => { + try { + pc!.addTrack(track, callLocalStream!); + } catch { + // ignore duplicate track errors + } + }); + } + + pc.onicecandidate = (event) => { + if (!event.candidate || !realtime || wsStatus() !== 'connected') return; + realtime.send({ + type: 'call.ice', + conversation_id: conversationID, + target_user_id: peerUserID, + candidate: event.candidate, + }); + }; + + pc.ontrack = (event) => { + const stream = event.streams[0]; + if (stream) { + attachRemoteAudio(peerUserID, stream); + } + }; + + pc.onconnectionstatechange = () => { + if (pc?.connectionState === 'failed' || pc?.connectionState === 'disconnected' || pc?.connectionState === 'closed') { + closePeerConnection(peerUserID); + if (callPeers.size === 0 && callStatus() === 'in_call') { + setCallStatus('idle'); + setActiveCallConversationId(null); + stopCallRecognition(); + } + } + }; + + return pc; + }; + + const handleIncomingCallOffer = async (payload: any) => { + const senderID = Number(payload?.sender_id || 0); + const targetID = Number(payload?.target_user_id || 0); + const conversationID = Number(payload?.conversation_id || payload?.conversationID || 0); + if (!senderID || !conversationID || !payload?.sdp) return; + if (targetID && targetID !== currentUserId()) return; + + try { + await ensureCallLocalStream(); + if (activeCallConversationId() && activeCallConversationId() !== conversationID) { + toast.warning('Incoming call ignored', 'You already have an active call in another conversation.'); + return; + } + + setActiveCallConversationId(conversationID); + if (selectedConversationId() !== conversationID) { + setSelectedConversationId(conversationID); + } + + const pc = buildPeerConnection(conversationID, senderID); + await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + realtime?.send({ + type: 'call.answer', + conversation_id: conversationID, + target_user_id: senderID, + sdp: answer, + }); + setCallStatus('in_call'); + startCallRecognition(); + toast.info('Voice call', 'Call connected.'); + } catch (error) { + setCallStatus('error'); + toast.error('Failed to answer call', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const handleIncomingCallAnswer = async (payload: any) => { + const senderID = Number(payload?.sender_id || 0); + const targetID = Number(payload?.target_user_id || 0); + if (!senderID || !payload?.sdp) return; + if (targetID && targetID !== currentUserId()) return; + + const pc = callPeers.get(senderID); + if (!pc) return; + try { + await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp)); + setCallStatus('in_call'); + startCallRecognition(); + } catch { + // ignore bad/late answers + } + }; + + const handleIncomingCallICE = async (payload: any) => { + const senderID = Number(payload?.sender_id || 0); + const targetID = Number(payload?.target_user_id || 0); + if (!senderID || !payload?.candidate) return; + if (targetID && targetID !== currentUserId()) return; + + const pc = callPeers.get(senderID); + if (!pc) return; + try { + await pc.addIceCandidate(new RTCIceCandidate(payload.candidate)); + } catch { + // ignore stale ice candidates + } + }; + + const cleanupCallResources = () => { + for (const userID of Array.from(callPeers.keys())) { + closePeerConnection(userID); + } + callPeers.clear(); + + if (callLocalStream) { + callLocalStream.getTracks().forEach((track) => track.stop()); + callLocalStream = null; + } + setCallMuted(false); + setActiveCallConversationId(null); + setActiveCallPeerIds([]); + setCallStatus('idle'); + stopCallRecognition(); + }; + + const endVoiceCall = (notifyRemote: boolean = true) => { + const conversationID = activeCallConversationId(); + if (notifyRemote && conversationID && realtime && wsStatus() === 'connected') { + realtime.send({ + type: 'call.hangup', + conversation_id: conversationID, + }); + } + cleanupCallResources(); + }; + + const startVoiceCall = async () => { + const conversationID = selectedConversationId(); + if (!conversationID) { + toast.warning('Conversation required', 'Select a conversation first.'); + return; + } + if (isCallActive()) return; + + const peers = conversationMembers() + .map((member) => member.user_id) + .filter((id) => id !== currentUserId()); + if (peers.length === 0) { + toast.warning('No participants', 'Add at least one other user to call.'); + return; + } + + try { + setCallStatus('starting'); + await ensureCallLocalStream(); + setActiveCallConversationId(conversationID); + setCallStatus('calling'); + + for (const peerID of peers) { + const pc = buildPeerConnection(conversationID, peerID); + const offer = await pc.createOffer({ + offerToReceiveAudio: true, + }); + await pc.setLocalDescription(offer); + realtime?.send({ + type: 'call.offer', + conversation_id: conversationID, + target_user_id: peerID, + sdp: offer, + }); + } + + startCallRecognition(); + } catch (error) { + setCallStatus('error'); + cleanupCallResources(); + toast.error('Failed to start call', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const toggleCallMute = () => { + if (!callLocalStream) return; + const nextMuted = !callMuted(); + callLocalStream.getAudioTracks().forEach((track) => { + track.enabled = !nextMuted; + }); + setCallMuted(nextMuted); + }; + + const loadConversations = async () => { + setLoadingConversations(true); + try { + const data = await messagesApi.listConversations(); + setConversations(data.conversations || []); + if (!selectedConversationId() && data.conversations?.length) { + setSelectedConversationId(data.conversations[0].conversation.id); + } + } catch (error) { + toast.error('Failed to load conversations', error instanceof Error ? error.message : 'Unknown error'); + } finally { + setLoadingConversations(false); + } + }; + + const loadMessages = async (conversationId: number) => { + setLoadingMessages(true); + try { + const data = await messagesApi.getMessages(conversationId, undefined, 50); + setMessages(data.messages || []); + } catch (error) { + toast.error('Failed to load messages', error instanceof Error ? error.message : 'Unknown error'); + } finally { + setLoadingMessages(false); + } + }; + + const loadConversationDetails = async (conversationId: number) => { + try { + const data = await messagesApi.getConversation(conversationId); + setConversationMembers(data.members || []); + } catch { + setConversationMembers([]); + } + }; + + const loadMembers = async () => { + const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''; + try { + const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/members?limit=200`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) return; + const data = await res.json(); + const mapped: MemberOption[] = (data.members || []).map((m: any) => ({ + id: Number(m.id), + username: m.username || '', + name: m.name || m.full_name || m.email || `User ${m.id}`, + })); + setMembers(mapped); + } catch { + // ignore + } + }; + + const loadVaultItems = async () => { + try { + const data = await messagesApi.listVaultItems(); + setVaultItems(data.items || []); + } catch (error) { + toast.error('Failed to load vault items', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const handleWsEvent = (event: any) => { + const eventType = event?.type || ''; + const eventConversationId = Number(event?.conversation_id || event?.data?.conversation_id || 0); + + if (eventType === 'message.created' && event?.data) { + const incoming: Message = event.data; + if (incoming.conversation_id === selectedConversationId()) { + setMessages((prev) => (prev.some((m) => m.id === incoming.id) ? prev : [...prev, incoming])); + } + setTypingByConversation((prev) => { + const convMap = { ...(prev[incoming.conversation_id] || {}) }; + delete convMap[incoming.sender_id]; + return { + ...prev, + [incoming.conversation_id]: convMap, + }; + }); + if (incoming.sender_id !== currentUserId()) { + const conv = conversations().find((c) => c.conversation.id === incoming.conversation_id); + const convName = conv?.conversation.name || 'New message'; + toast.info(convName, incoming.body || 'New message received'); + maybeNotify(convName, incoming.body || 'New message received'); + } + loadConversations(); + return; + } + + if (eventType === 'message.updated' && event?.data) { + const updated: Message = event.data; + setMessages((prev) => prev.map((m) => (m.id === updated.id ? { ...m, ...updated } : m))); + return; + } + + if (eventType === 'message.deleted' && event?.data) { + const deletedId = Number(event.data.message_id); + setMessages((prev) => prev.map((m) => (m.id === deletedId ? { ...m, body: '[deleted]', deleted_at: new Date().toISOString() } : m))); + return; + } + + if (eventType === 'reaction.added' && event?.data) { + const reaction = event.data; + setMessages((prev) => + prev.map((m) => + m.id === reaction.message_id + ? { ...m, reactions: [...(m.reactions || []), reaction] } + : m + ) + ); + return; + } + + if (eventType === 'reaction.removed' && event?.data) { + const payload = event.data; + setMessages((prev) => + prev.map((m) => + m.id === payload.message_id + ? { + ...m, + reactions: (m.reactions || []).filter( + (r) => !(r.user_id === payload.user_id && r.emoji === payload.emoji) + ), + } + : m + ) + ); + return; + } + + if ((eventType === 'typing.started' || eventType === 'typing.stopped') && event?.data) { + const payload = event.data; + const conversationID = Number(payload.conversation_id || eventConversationId); + const userID = Number(payload.user_id || 0); + if (!conversationID || !userID || userID === currentUserId()) return; + + if (eventType === 'typing.started') { + setTypingByConversation((prev) => { + const convMap = { ...(prev[conversationID] || {}) }; + convMap[userID] = Date.now(); + return { + ...prev, + [conversationID]: convMap, + }; + }); + } else { + setTypingByConversation((prev) => { + const convMap = { ...(prev[conversationID] || {}) }; + delete convMap[userID]; + return { + ...prev, + [conversationID]: convMap, + }; + }); + } + return; + } + + if (eventType === 'call.offer' && event?.data) { + void handleIncomingCallOffer(event.data); + return; + } + + if (eventType === 'call.answer' && event?.data) { + void handleIncomingCallAnswer(event.data); + return; + } + + if (eventType === 'call.ice' && event?.data) { + void handleIncomingCallICE(event.data); + return; + } + + if (eventType === 'call.hangup' && event?.data) { + const conversationID = Number(event.data.conversation_id || eventConversationId); + if (conversationID && activeCallConversationId() === conversationID) { + cleanupCallResources(); + toast.info('Voice call', 'Call ended.'); + } + return; + } + + if (eventType === 'conversation.updated' || eventConversationId > 0) { + loadConversations(); + } + }; + + const startRealtime = () => { + realtime = new MessagesRealtimeClient(handleWsEvent, (status) => { + setWsStatus(status); + }); + realtime.connect(); + }; + + const startPollingFallback = () => { + if (pollInterval) return; + pollInterval = window.setInterval(() => { + loadConversations(); + if (selectedConversationId()) { + loadMessages(selectedConversationId()!); + } + }, 10000); + }; + + const stopPollingFallback = () => { + if (!pollInterval) return; + window.clearInterval(pollInterval); + pollInterval = null; + }; + + const onFileSelect = (event: Event) => { + const target = event.currentTarget as HTMLInputElement; + const files = Array.from(target.files || []); + setSelectedFiles(files); + }; + + const sendMessage = async () => { + if (!selectedConversationId()) return; + const body = inputText().trim(); + if (!body && selectedFiles().length === 0) return; + + try { + const attachments: any[] = []; + for (const file of selectedFiles()) { + const uploaded = await uploadChatFile(file); + attachments.push({ + kind: uploaded.mime_type?.startsWith('image/') ? 'image' : 'file', + file_id: uploaded.id, + title: uploaded.original_name, + url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`, + }); + } + + await postMessage(body, attachments); + setInputText(''); + setSelectedFiles([]); + } catch (error) { + toast.error('Failed to send message', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const handleSuggestionAction = async (messageId: number, suggestion: MessageSuggestion, action: 'accept' | 'dismiss') => { + try { + if (action === 'accept') { + await messagesApi.acceptSuggestion(messageId, suggestion.id, { + redact_original: suggestion.type === 'move_to_password_vault', + }); + toast.success('Suggestion applied'); + } else { + await messagesApi.dismissSuggestion(messageId, suggestion.id); + } + if (selectedConversationId()) { + await loadMessages(selectedConversationId()!); + } + if (suggestion.type === 'move_to_password_vault') { + await loadVaultItems(); + } + } catch (error) { + toast.error('Failed to apply suggestion', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const addReaction = async (messageId: number, emoji: string) => { + try { + await messagesApi.addReaction(messageId, emoji); + } catch { + // ignore reaction errors in quick UI + } + }; + + const performSearch = async () => { + setSearching(true); + try { + const data = await messagesApi.searchMessages({ + query: searchQuery(), + conversation_ids: searchConversationIds().length ? searchConversationIds() : undefined, + sender_id: searchSenderId().trim() ? Number(searchSenderId()) : undefined, + date_from: toISOOrUndefined(searchDateFrom()), + date_to: toISOOrUndefined(searchDateTo()), + attachment_kinds: searchAttachmentKinds().length ? searchAttachmentKinds() : undefined, + reference_types: searchReferenceTypes().length ? searchReferenceTypes() : undefined, + has_links: triStateToBool(searchHasLinks()), + has_attachments: triStateToBool(searchHasAttachments()), + has_suggestions: triStateToBool(searchHasSuggestions()), + mention_only: searchMentionOnly(), + limit: 50, + offset: 0, + }); + setSearchResults(data.results || []); + } catch (error) { + toast.error('Search failed', error instanceof Error ? error.message : 'Unknown error'); + } finally { + setSearching(false); + } + }; + + const openSearchResult = async (result: Message) => { + setSelectedConversationId(result.conversation_id); + setSearchOpen(false); + await loadMessages(result.conversation_id); + }; + + const createConversation = async () => { + try { + const type = newConversationType(); + let payload: any = { + type, + name: newConversationName().trim(), + topic: newConversationTopic().trim(), + }; + + if (type === 'dm') { + payload.user_ids = [Number(targetUserId())]; + } else if (type === 'group') { + payload.user_ids = groupUserIds() + .split(',') + .map((part) => Number(part.trim())) + .filter((id) => Number.isFinite(id) && id > 0); + } else if (type === 'team') { + payload.team_id = Number(teamId()); + } + + const data = await messagesApi.createConversation(payload); + setShowCreateConversation(false); + setNewConversationName(''); + setNewConversationTopic(''); + setTargetUserId(''); + setGroupUserIds(''); + setTeamId(''); + await loadConversations(); + setSelectedConversationId(data.conversation.id); + await loadMessages(data.conversation.id); + } catch (error) { + toast.error('Failed to create conversation', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const revealVaultItem = async (item: VaultItem) => { + try { + const revealed = await messagesApi.revealVaultItem(item.id); + setRevealedSecrets((prev) => ({ + ...prev, + [item.id]: { secret: revealed.secret, notes: revealed.notes || '' }, + })); + toast.warning('Password Safety', revealed.warning || 'Handle revealed secrets with care.'); + } catch (error) { + toast.error('Failed to reveal vault item', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + const shareVaultItem = async (item: VaultItem) => { + const targetRaw = shareTargets()[item.id]; + const targetConversationID = Number(targetRaw); + if (!targetConversationID) { + toast.warning('Target required', 'Enter a valid conversation ID.'); + return; + } + try { + await messagesApi.shareVaultItem(item.id, { + target_conversation_id: targetConversationID, + allow_reveal: true, + }); + toast.success('Vault item shared'); + await loadVaultItems(); + } catch (error) { + toast.error('Failed to share vault item', error instanceof Error ? error.message : 'Unknown error'); + } + }; + + onMount(async () => { + await Promise.all([loadConversations(), loadMembers(), loadVaultItems()]); + startRealtime(); + typingCleanupTimer = window.setInterval(() => { + const cutoff = Date.now() - 6000; + setTypingByConversation((prev) => { + let changed = false; + const next: Record> = {}; + for (const [convIDRaw, users] of Object.entries(prev)) { + const convID = Number(convIDRaw); + const filtered: Record = {}; + for (const [userIDRaw, startedAt] of Object.entries(users)) { + if (Number(startedAt) >= cutoff) { + filtered[Number(userIDRaw)] = Number(startedAt); + } else { + changed = true; + } + } + next[convID] = filtered; + } + return changed ? next : prev; + }); + }, 1500); + }); + + onCleanup(() => { + markTypingStopped(); + discardVoiceRecording = true; + stopVoiceRecording(); + cleanupCallResources(); + if (typingCleanupTimer) { + window.clearInterval(typingCleanupTimer); + typingCleanupTimer = null; + } + if (typingStopTimer) { + window.clearTimeout(typingStopTimer); + typingStopTimer = null; + } + stopPollingFallback(); + realtime?.disconnect(); + }); + + // Keep message list in sync with selected conversation + const syncSelectedConversation = async () => { + if (!selectedConversationId()) return; + await Promise.all([ + loadMessages(selectedConversationId()!), + loadConversationDetails(selectedConversationId()!), + ]); + }; + + // Re-run loading whenever selected conversation changes + let lastConversationId: number | null = null; + const selectionWatcher = setInterval(() => { + const current = selectedConversationId(); + if (current && current !== lastConversationId) { + if (lastConversationId) { + markTypingStopped(lastConversationId); + } + lastConversationId = current; + syncSelectedConversation(); + } + }, 250); + onCleanup(() => clearInterval(selectionWatcher)); + + // Fallback polling when websocket is disconnected. + const wsWatcher = setInterval(() => { + if (wsStatus() === 'connected') { + stopPollingFallback(); + } else { + startPollingFallback(); + } + }, 1500); + onCleanup(() => clearInterval(wsWatcher)); + + return ( +
+ {/* Left rail */} +
+
+
+
+ +

Messages

+
+ +
+
+ + +
+
+ +
+ Status: {wsStatus()} + + Refreshing… + +
+ +
+ + {(item) => ( + + )} + +
+
+ + {/* Center timeline */} +
+
+
+

{activeConversation()?.conversation.name || 'Select a conversation'}

+

+ {activeConversation()?.conversation.topic || activeConversation()?.conversation.type || ''} +

+
+
+ + + + + + +
+
+ +
+ + Call status: {callStatus()} + 0}> + • with {activeCallPeerIds().length} peer{activeCallPeerIds().length > 1 ? 's' : ''} + + + +
+
+ +
+ Call transcript preview: {callTranscriptPreview()} +
+
+ +
+ +

Loading messages…

+
+ + + {(message) => ( +
+
+
+
+ + {(message.sender?.full_name || message.sender?.username || 'U').charAt(0).toUpperCase()} + + } + > + {message.sender?.username + +
+ + {message.sender?.full_name || message.sender?.username || `User ${message.sender_id}`} + + + (edited) + +
+

{message.body}

+ + +
+ Sensitive content detected. We recommend Proton Pass (not affiliated). +
+
+ + 0}> +
+ + {(att) => ( + + {att.kind} + {att.title || att.url} + + } + > +
+
{att.title || 'Voice note'}
+
+
+ )} +
+
+
+ + 0}> +
+ + {(ref) => ( + + {ref.entity_type} + + )} + +
+
+ + s.status === 'pending')}> +
+ s.status === 'pending')}> + {(suggestion) => ( +
+
{suggestion.type}
+ +
+ We do not recommend storing passwords in chat. If needed, move it to encrypted vault storage. +
+
+
+ + +
+
+ )} +
+
+
+ +
+ + + + 0}> +
+ {(message.reactions || []).map((r) => r.emoji).join(' ')} +
+
+
+
+
+ )} +
+
+ +
+ 0}> +
+ {activeTypingUserNames().length === 1 + ? `${activeTypingUserNames()[0]} is typing...` + : `${activeTypingUserNames().slice(0, 2).join(', ')}${activeTypingUserNames().length > 2 ? ' and others' : ''} are typing...`} +
+
+
+ + + + 0}> + + {selectedFiles().length} file{selectedFiles().length > 1 ? 's' : ''} selected + + + + + Recording {formatMs(voiceRecordingMs())} + + +
+ +
+ Voice transcript preview: {voiceTranscriptPreview()} +
+
+
+ { + const value = (e.currentTarget as HTMLInputElement).value; + setInputText(value); + if (value.trim()) { + markTypingStarted(); + } else { + markTypingStopped(); + } + }} + placeholder='Message (try links, tasks, events, or "@username" mentions)' + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }} + /> + +
+
+
+ + {/* Right rail */} +
+
+

+ + Details +

+

+ Members, vault items, and quick actions +

+
+ +
+ +

Conversation Info

+

ID: {selectedConversationId() || '-'}

+

Type: {activeConversation()?.conversation.type || '-'}

+
+ + +

Members

+ 0} + fallback={

No members loaded.

} + > +
+ + {(member) => ( +
+
+
+ + {(member.user?.full_name || member.user?.username || 'U').charAt(0).toUpperCase()} + + } + > + {member.user?.username + +
+
+
+ {member.user?.full_name || member.user?.username || `User ${member.user_id}`} +
+
@{member.user?.username || member.user_id}
+
+
+ {member.role} +
+ )} +
+
+
+
+ + +
+

Password Vault

+ +
+ +
+ + {(item) => ( +
+
{item.label}
+
+ Owner: {item.owner_user_id} {item.shared ? '• Shared' : ''} +
+ +
+
Secret: {revealedSecrets()[item.id].secret}
+ +
Notes: {revealedSecrets()[item.id].notes}
+
+
+
+
+ +
+
+ + setShareTargets((prev) => ({ + ...prev, + [item.id]: (e.currentTarget as HTMLInputElement).value, + })) + } + placeholder="Conversation ID" + /> + +
+
+ )} +
+
+
+
+ + +

Quick Create Conversation

+ +
+
+
+ + {/* Create conversation modal */} + +
+
+
+

Create Conversation

+ +
+ +
+ + + +
+ + + setNewConversationName((e.currentTarget as HTMLInputElement).value)} + placeholder="Conversation name" + /> + + setNewConversationTopic((e.currentTarget as HTMLInputElement).value)} + placeholder="Topic (optional)" + /> + + + + + + + setGroupUserIds((e.currentTarget as HTMLInputElement).value)} + placeholder="Member user IDs, comma-separated (e.g. 2,3,7)" + /> + + + + setTeamId((e.currentTarget as HTMLInputElement).value)} + placeholder="Team ID" + /> + + +
+ + +
+
+
+
+ + {/* Search modal */} + +
+
+
+

Search Messages

+ +
+
+
+ setSearchQuery((e.currentTarget as HTMLInputElement).value)} + placeholder="Search text, file type, URLs, mentions..." + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') performSearch(); + }} + /> + +
+
+
+ + +
+
+ +
+
+ + setSearchDateFrom((e.currentTarget as HTMLInputElement).value)} + /> +
+
+ + setSearchDateTo((e.currentTarget as HTMLInputElement).value)} + /> +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ + {(item) => ( + + )} + +
+
+ +
+ +
+ + {(kind) => ( + + )} + +
+
+ +
+ +
+ + {(refType) => ( + + )} + +
+
+
+ + {(result) => ( + + )} + + +
No results yet.
+
+
+
+
+
+
+
+ ); +}; + +export default Messages; diff --git a/go.mod b/go.mod index 3d4c403..4ac8d10 100644 --- a/go.mod +++ b/go.mod @@ -1,38 +1,3 @@ module test-youtube go 1.25.6 - -require ( - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect - github.com/cloudwego/base64x v0.1.6 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.11.0 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 225abc8..0000000 --- a/go.sum +++ /dev/null @@ -1,47 +0,0 @@ -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=