From 9d03ea4c5108b4cfc54dbd86972f7c956d463218 Mon Sep 17 00:00:00 2001 From: vantablackTea Date: Sun, 27 Sep 2026 21:59:48 +0300 Subject: [PATCH] big update: added reactions, groups, media share --- server/cmd/api/main.go | 9 +- server/go.mod | 1 + server/go.sum | 2 + server/internal/api/api.go | 16 +- server/internal/api/conversations.go | 288 ++++++++++++++++-- server/internal/api/media.go | 172 +++++++++++ server/internal/api/reactions.go | 93 ++++++ server/internal/media/local.go | 95 ++++++ server/internal/media/storage.go | 19 ++ server/internal/media/storage_test.go | 73 +++++ server/internal/media/thumbnail.go | 82 +++++ server/internal/media/thumbnail_test.go | 73 +++++ server/internal/platform/config/config.go | 3 + server/internal/realtime/events.go | 57 +++- server/internal/realtime/hub.go | 61 ++++ server/internal/server/server.go | 5 +- server/internal/store/conversations.go | 16 +- server/internal/store/groups.go | 121 ++++++++ server/internal/store/integration_test.go | 72 ++++- server/internal/store/media.go | 62 ++++ server/internal/store/messages.go | 23 +- .../internal/store/migrations/0002_groups.sql | 5 + .../store/migrations/0003_reactions.sql | 9 + .../internal/store/migrations/0004_media.sql | 14 + server/internal/store/reactions.go | 71 +++++ server/internal/store/store.go | 10 + web/src/components/Avatar.tsx | 24 ++ web/src/components/ChatWindow.tsx | 192 ++++++++++-- web/src/components/MembersDialog.tsx | 240 +++++++++++++++ web/src/components/MessageBubble.tsx | 215 ++++++++++--- web/src/components/MessageList.tsx | 4 + web/src/components/NewChatDialog.tsx | 169 ++++++++-- web/src/components/Sidebar.tsx | 45 ++- web/src/components/icons.tsx | 58 ++++ web/src/hooks/useConversations.ts | 7 +- web/src/hooks/useMessages.ts | 6 + web/src/lib/api.ts | 87 +++++- web/src/lib/format.ts | 9 + web/src/lib/types.ts | 91 +++++- web/src/lib/ws.ts | 13 +- web/src/screens/ChatApp.tsx | 108 ++++++- 41 files changed, 2548 insertions(+), 172 deletions(-) create mode 100644 server/internal/api/media.go create mode 100644 server/internal/api/reactions.go create mode 100644 server/internal/media/local.go create mode 100644 server/internal/media/storage.go create mode 100644 server/internal/media/storage_test.go create mode 100644 server/internal/media/thumbnail.go create mode 100644 server/internal/media/thumbnail_test.go create mode 100644 server/internal/store/groups.go create mode 100644 server/internal/store/media.go create mode 100644 server/internal/store/migrations/0002_groups.sql create mode 100644 server/internal/store/migrations/0003_reactions.sql create mode 100644 server/internal/store/migrations/0004_media.sql create mode 100644 server/internal/store/reactions.go create mode 100644 web/src/components/MembersDialog.tsx diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 52f01c4..7e5e67f 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "time" "messenger/internal/auth" + "messenger/internal/media" "messenger/internal/platform/config" "messenger/internal/platform/logger" "messenger/internal/realtime" @@ -52,7 +53,13 @@ func main() { tm := auth.NewTokenManager(cfg.JWTSecret) hub := realtime.NewHub(st) - httpServer := server.New(cfg, log, st, tm, hub) + mediaStore, err := media.NewLocal(cfg.MediaDir) + if err != nil { + log.Error("failed to init media storage", "error", err) + os.Exit(1) + } + + httpServer := server.New(cfg, log, st, tm, hub, mediaStore) // Run the server in a goroutine so that shutdown can be triggered // from the signal handler below. diff --git a/server/go.mod b/server/go.mod index d6633a7..6b17e82 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.11.0 golang.org/x/crypto v0.57.0 + golang.org/x/image v0.46.0 ) require ( diff --git a/server/go.sum b/server/go.sum index 07e73a6..d1e0717 100644 --- a/server/go.sum +++ b/server/go.sum @@ -24,6 +24,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= diff --git a/server/internal/api/api.go b/server/internal/api/api.go index cb8cbcb..4b212a4 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -6,6 +6,7 @@ import ( "strings" "messenger/internal/auth" + "messenger/internal/media" "messenger/internal/platform/config" "messenger/internal/realtime" "messenger/internal/store" @@ -17,17 +18,19 @@ type apiHandler struct { hub *realtime.Hub log *slog.Logger secure bool + media media.Storage } // NewHandler builds the /api/v1 route tree with all middleware applied. // static, when non-nil, serves the built frontend for non-API paths. -func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *slog.Logger, cfg *config.Config, static http.Handler) http.Handler { +func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *slog.Logger, cfg *config.Config, ms media.Storage, static http.Handler) http.Handler { h := &apiHandler{ store: st, tokens: tm, hub: hub, log: log, secure: cfg.Environment == "production", + media: ms, } mux := http.NewServeMux() @@ -50,9 +53,18 @@ func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log * mux.Handle("DELETE /api/v1/contacts/{user_id}", requireAuth(tm, h.removeContact)) mux.Handle("GET /api/v1/conversations", requireAuth(tm, h.listConversations)) - mux.Handle("POST /api/v1/conversations", requireAuth(tm, h.createDM)) + mux.Handle("POST /api/v1/conversations", requireAuth(tm, h.createConversation)) + mux.Handle("GET /api/v1/conversations/{id}/members", requireAuth(tm, h.listMembers)) + mux.Handle("POST /api/v1/conversations/{id}/members", requireAuth(tm, h.addMember)) + mux.Handle("DELETE /api/v1/conversations/{id}/members/{user_id}", requireAuth(tm, h.removeMember)) mux.Handle("GET /api/v1/conversations/{id}/messages", requireAuth(tm, h.listMessages)) mux.Handle("POST /api/v1/conversations/{id}/messages", requireAuth(tm, h.sendMessage)) + mux.Handle("PUT /api/v1/conversations/{id}/messages/{msg_id}/reactions", requireAuth(tm, h.addReaction)) + mux.Handle("DELETE /api/v1/conversations/{id}/messages/{msg_id}/reactions/{emoji}", requireAuth(tm, h.removeReaction)) + + mux.Handle("POST /api/v1/media", requireAuth(tm, h.uploadMedia)) + mux.Handle("GET /api/v1/media/{id}", requireAuth(tm, h.downloadMedia)) + mux.Handle("GET /api/v1/media/{id}/thumb", requireAuth(tm, h.downloadThumb)) mux.Handle("GET /ws", authHandler) diff --git a/server/internal/api/conversations.go b/server/internal/api/conversations.go index 61e3d7d..a969e5d 100644 --- a/server/internal/api/conversations.go +++ b/server/internal/api/conversations.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" "messenger/internal/realtime" @@ -13,19 +14,34 @@ import ( type conversationJSON struct { ID int64 `json:"id"` Type string `json:"type"` + Name string `json:"name"` + Avatar string `json:"avatar"` CreatedAt time.Time `json:"created_at"` LastMessage *messageJSON `json:"last_message,omitempty"` UnreadCount int64 `json:"unread_count"` } +type memberJSON struct { + User userJSON `json:"user"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` +} + type messageJSON struct { - ID int64 `json:"id"` - ConversationID int64 `json:"conversation_id"` - SenderID int64 `json:"sender_id"` - Body string `json:"body"` - CreatedAt time.Time `json:"created_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + ID int64 `json:"id"` + ConversationID int64 `json:"conversation_id"` + SenderID int64 `json:"sender_id"` + Body string `json:"body"` + Reactions []reactionJSON `json:"reactions"` + Media *mediaJSON `json:"media,omitempty"` + CreatedAt time.Time `json:"created_at"` + EditedAt *time.Time `json:"edited_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +type reactionJSON struct { + Emoji string `json:"emoji"` + UserID int64 `json:"user_id"` } func toMessageJSON(m *store.Message) messageJSON { @@ -34,12 +50,25 @@ func toMessageJSON(m *store.Message) messageJSON { ConversationID: m.ConversationID, SenderID: m.SenderID, Body: m.Body, + Reactions: []reactionJSON{}, CreatedAt: m.CreatedAt, EditedAt: m.EditedAt, DeletedAt: m.DeletedAt, } } +func attachReactions(msgs []messageJSON, reactions []store.Reaction) { + byMessage := make(map[int64][]reactionJSON) + for _, r := range reactions { + byMessage[r.MessageID] = append(byMessage[r.MessageID], reactionJSON{Emoji: r.Emoji, UserID: r.UserID}) + } + for i := range msgs { + if rs, ok := byMessage[msgs[i].ID]; ok { + msgs[i].Reactions = rs + } + } +} + func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) summaries, err := h.store.ListConversationsForUser(r.Context(), userID) @@ -52,6 +81,8 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) { item := conversationJSON{ ID: s.ID, Type: s.Type, + Name: s.Name, + Avatar: s.Avatar, CreatedAt: s.CreatedAt, UnreadCount: s.UnreadCount, } @@ -64,12 +95,27 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) { +type createConversationRequest struct { + Type string `json:"type"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + MemberIDs []int64 `json:"member_ids"` +} + +func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) - var req struct { - UserID int64 `json:"user_id"` + var req createConversationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { + + if req.Type == "group" || req.Name != "" { + h.createGroup(w, r, userID, req) + return + } + + if req.UserID <= 0 { writeError(w, http.StatusBadRequest, "invalid user_id") return } @@ -82,7 +128,148 @@ func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "internal server error") return } - writeJSON(w, http.StatusCreated, conversationJSON{ID: conv.ID, Type: conv.Type, CreatedAt: conv.CreatedAt}) + writeJSON(w, http.StatusCreated, conversationJSON{ + ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt, + }) +} + +func (h *apiHandler) createGroup(w http.ResponseWriter, r *http.Request, userID int64, req createConversationRequest) { + name := strings.TrimSpace(req.Name) + if name == "" || len(name) > 64 { + writeError(w, http.StatusBadRequest, "name must be 1-64 chars") + return + } + for _, uid := range req.MemberIDs { + if uid == userID { + continue + } + if _, err := h.store.GetUserByID(r.Context(), uid); err != nil { + writeError(w, http.StatusBadRequest, "invalid member_id") + return + } + } + conv, err := h.store.CreateGroup(r.Context(), userID, name, req.MemberIDs) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + h.hub.Subscribe(userID, conv.ID) + for _, uid := range req.MemberIDs { + if uid == userID { + continue + } + h.hub.Subscribe(uid, conv.ID) + h.hub.SendToUser(uid, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: conv.ID}) + } + writeJSON(w, http.StatusCreated, conversationJSON{ + ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt, + }) +} + +func (h *apiHandler) listMembers(w http.ResponseWriter, r *http.Request) { + userID, _ := userIDFrom(r.Context()) + convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || convID <= 0 { + writeError(w, http.StatusBadRequest, "invalid conversation id") + return + } + if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member { + writeError(w, http.StatusForbidden, "not a member") + return + } + members, err := h.store.ListMembers(r.Context(), convID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + out := make([]memberJSON, 0, len(members)) + for _, m := range members { + out = append(out, memberJSON{User: toUserJSON(&m.User), Role: m.Role, JoinedAt: m.JoinedAt}) + } + writeJSON(w, http.StatusOK, out) +} + +func (h *apiHandler) addMember(w http.ResponseWriter, r *http.Request) { + userID, _ := userIDFrom(r.Context()) + convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || convID <= 0 { + writeError(w, http.StatusBadRequest, "invalid conversation id") + return + } + if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member { + writeError(w, http.StatusForbidden, "not a member") + return + } + conv, err := h.store.GetConversationByID(r.Context(), convID) + if err != nil { + writeError(w, http.StatusNotFound, "conversation not found") + return + } + if conv.Type != "group" { + writeError(w, http.StatusBadRequest, "only groups support members") + return + } + var req struct { + UserID int64 `json:"user_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { + writeError(w, http.StatusBadRequest, "invalid user_id") + return + } + if _, err := h.store.GetUserByID(r.Context(), req.UserID); err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + if err := h.store.AddMember(r.Context(), convID, req.UserID); err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + h.hub.Subscribe(req.UserID, convID) + h.hub.SendToUser(req.UserID, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: convID}) + h.hub.PublishMemberChanged(convID, req.UserID, true) + writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"}) +} + +func (h *apiHandler) removeMember(w http.ResponseWriter, r *http.Request) { + userID, _ := userIDFrom(r.Context()) + convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || convID <= 0 { + writeError(w, http.StatusBadRequest, "invalid conversation id") + return + } + targetID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64) + if err != nil || targetID <= 0 { + writeError(w, http.StatusBadRequest, "invalid user_id") + return + } + + requesterRole, err := h.store.MemberRole(r.Context(), convID, userID) + if err != nil { + writeError(w, http.StatusForbidden, "not a member") + return + } + targetRole, err := h.store.MemberRole(r.Context(), convID, targetID) + if err != nil { + writeError(w, http.StatusNotFound, "member not found") + return + } + if targetRole == "owner" { + writeError(w, http.StatusBadRequest, "cannot remove the owner") + return + } + if userID != targetID && requesterRole != "owner" { + writeError(w, http.StatusForbidden, "only the owner can remove others") + return + } + + if err := h.store.RemoveMember(r.Context(), convID, targetID); err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + h.hub.Unsubscribe(targetID, convID) + h.hub.SendToUser(targetID, realtime.EventConversationRemoved, realtime.ConversationChanged{ConversationID: convID}) + h.hub.PublishMemberChanged(convID, targetID, false) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) { @@ -115,15 +302,46 @@ func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) { return } out := make([]messageJSON, 0, len(messages)) + msgIDs := make([]int64, 0, len(messages)) + mediaIDs := make(map[int64]struct{}) for i := range messages { out = append(out, toMessageJSON(&messages[i])) + msgIDs = append(msgIDs, messages[i].ID) + if messages[i].MediaID != nil { + mediaIDs[*messages[i].MediaID] = struct{}{} + } + } + reactions, err := h.store.ReactionsForMessages(r.Context(), msgIDs) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + attachReactions(out, reactions) + + mediaByID := make(map[int64]mediaJSON) + for id := range mediaIDs { + m, err := h.store.GetMediaByID(r.Context(), id) + if err == nil { + mediaByID[id] = toMediaJSON(m) + } + } + for i := range out { + for j := range messages { + if messages[j].ID == out[i].ID && messages[j].MediaID != nil { + if md, ok := mediaByID[*messages[j].MediaID]; ok { + out[i].Media = &md + } + break + } + } } writeJSON(w, http.StatusOK, out) } type sendMessageRequest struct { - Body string `json:"body"` - TempID string `json:"temp_id"` + Body string `json:"body"` + TempID string `json:"temp_id"` + MediaID *int64 `json:"media_id,omitempty"` } func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) { @@ -144,8 +362,8 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid json") return } - if req.Body == "" { - writeError(w, http.StatusBadRequest, "body is required") + if req.Body == "" && req.MediaID == nil { + writeError(w, http.StatusBadRequest, "body or media_id is required") return } if len(req.Body) > 4000 { @@ -153,20 +371,50 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) { return } - msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body) + var attached *store.Media + if req.MediaID != nil { + attached, err = h.store.GetMediaByID(r.Context(), *req.MediaID) + if err != nil || attached.UploaderID != userID { + writeError(w, http.StatusBadRequest, "invalid media_id") + return + } + } + + msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body, req.MediaID) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } - h.hub.PublishMessageCreated(convID, realtime.MessageCreated{ + wsEvent := realtime.MessageCreated{ TempID: req.TempID, ConversationID: convID, MessageID: msg.ID, SenderID: userID, Body: msg.Body, CreatedAt: msg.CreatedAt, - }) + } + if attached != nil { + wsEvent.MediaID = &attached.ID + info := toRealtimeMedia(attached) + wsEvent.Media = &info + } + h.hub.PublishMessageCreated(convID, wsEvent) - writeJSON(w, http.StatusCreated, toMessageJSON(msg)) + out := toMessageJSON(msg) + if attached != nil { + md := toMediaJSON(attached) + out.Media = &md + } + writeJSON(w, http.StatusCreated, out) +} + +func toRealtimeMedia(m *store.Media) realtime.MediaInfo { + return realtime.MediaInfo{ + ID: m.ID, + Filename: m.Filename, + ContentType: m.ContentType, + Size: m.Size, + URL: "/api/v1/media/" + strconv.FormatInt(m.ID, 10), + } } diff --git a/server/internal/api/media.go b/server/internal/api/media.go new file mode 100644 index 0000000..e9be780 --- /dev/null +++ b/server/internal/api/media.go @@ -0,0 +1,172 @@ +package api + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strconv" + "strings" + + "messenger/internal/media" + "messenger/internal/store" +) + +const maxUploadBytes = 25 << 20 // 25 MiB + +type mediaJSON struct { + ID int64 `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + URL string `json:"url"` + ThumbURL string `json:"thumb_url,omitempty"` +} + +func toMediaJSON(m *store.Media) mediaJSON { + out := mediaJSON{ + ID: m.ID, + Filename: m.Filename, + ContentType: m.ContentType, + Size: m.Size, + URL: fmt.Sprintf("/api/v1/media/%d", m.ID), + } + if m.ThumbKey != "" { + out.ThumbURL = fmt.Sprintf("/api/v1/media/%d/thumb", m.ID) + } + return out +} + +// uploadMedia accepts a multipart file and stores it, generating a thumbnail +// for images. Media is accessible to any authenticated user; conversation +// membership checks are a documented family-scale simplification. +func (h *apiHandler) uploadMedia(w http.ResponseWriter, r *http.Request) { + userID, _ := userIDFrom(r.Context()) + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20) + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "missing file field") + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read file") + return + } + if len(data) == 0 { + writeError(w, http.StatusBadRequest, "empty file") + return + } + if len(data) > maxUploadBytes { + writeError(w, http.StatusBadRequest, "file too large (max 25 MiB)") + return + } + + contentType := header.Header.Get("Content-Type") + if contentType == "" || strings.HasPrefix(contentType, "application/octet-stream") { + contentType = http.DetectContentType(data) + } + + id := randomHex(16) + ext := filepath.Ext(header.Filename) + storageKey := fmt.Sprintf("u%d/%s%s", userID, id, ext) + if err := h.media.Save(r.Context(), storageKey, bytes.NewReader(data)); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store file") + return + } + + thumbKey := "" + if media.IsImage(contentType) { + thumb, err := media.Generate(r.Context(), bytes.NewReader(data), contentType, 512) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate thumbnail") + return + } + if thumb != nil { + thumbKey = fmt.Sprintf("u%d/%s_thumb.jpg", userID, id) + if err := h.media.Save(r.Context(), thumbKey, bytes.NewReader(thumb)); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store thumbnail") + return + } + } + } + + created, err := h.store.CreateMedia(r.Context(), store.Media{ + UploaderID: userID, + Filename: header.Filename, + ContentType: contentType, + Size: int64(len(data)), + StorageKey: storageKey, + ThumbKey: thumbKey, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + writeJSON(w, http.StatusCreated, toMediaJSON(created)) +} + +func (h *apiHandler) downloadMedia(w http.ResponseWriter, r *http.Request) { + h.serveStoredFile(w, r, func(m *store.Media) (string, string, int64) { + return m.StorageKey, m.ContentType, m.Size + }) +} + +func (h *apiHandler) downloadThumb(w http.ResponseWriter, r *http.Request) { + h.serveStoredFile(w, r, func(m *store.Media) (string, string, int64) { + if m.ThumbKey == "" { + return "", "", 0 + } + return m.ThumbKey, "image/jpeg", 0 + }) +} + +func (h *apiHandler) serveStoredFile(w http.ResponseWriter, r *http.Request, pick func(*store.Media) (key, contentType string, size int64)) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "invalid media id") + return + } + m, err := h.store.GetMediaByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "media not found") + return + } + key, contentType, size := pick(m) + if key == "" { + writeError(w, http.StatusNotFound, "thumbnail not available") + return + } + rc, err := h.media.Open(r.Context(), key) + if err != nil { + writeError(w, http.StatusNotFound, "file not found") + return + } + defer rc.Close() + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + if ext := filepath.Ext(m.Filename); ext != "" { + if cd := mime.TypeByExtension(ext); cd == "" { + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", m.Filename)) + } + } + if size > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + } + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, rc) +} + +func randomHex(n int) string { + buf := make([]byte, n) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} diff --git a/server/internal/api/reactions.go b/server/internal/api/reactions.go new file mode 100644 index 0000000..a9deeb1 --- /dev/null +++ b/server/internal/api/reactions.go @@ -0,0 +1,93 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" +) + +func (h *apiHandler) addReaction(w http.ResponseWriter, r *http.Request) { + userID, _ := userIDFrom(r.Context()) + convID, msgID, ok := h.parseMessagePath(w, r) + if !ok { + return + } + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + if len(req.Emoji) == 0 || len(req.Emoji) > 32 { + writeError(w, http.StatusBadRequest, "emoji must be 1-32 chars") + return + } + + if err := h.store.AddReaction(r.Context(), msgID, userID, req.Emoji); err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + h.hub.PublishReactionChanged(convID, msgID, userID, req.Emoji, true) + h.writeMessageWithReactions(w, r, convID, msgID) +} + +func (h *apiHandler) removeReaction(w http.ResponseWriter, r *http.Request) { + userID, _ := userIDFrom(r.Context()) + convID, msgID, ok := h.parseMessagePath(w, r) + if !ok { + return + } + emoji := r.PathValue("emoji") + if emoji == "" { + writeError(w, http.StatusBadRequest, "emoji is required") + return + } + + if err := h.store.RemoveReaction(r.Context(), msgID, userID, emoji); err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + h.hub.PublishReactionChanged(convID, msgID, userID, emoji, false) + h.writeMessageWithReactions(w, r, convID, msgID) +} + +func (h *apiHandler) parseMessagePath(w http.ResponseWriter, r *http.Request) (convID, msgID int64, ok bool) { + userID, _ := userIDFrom(r.Context()) + convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || convID <= 0 { + writeError(w, http.StatusBadRequest, "invalid conversation id") + return 0, 0, false + } + msgID, err = strconv.ParseInt(r.PathValue("msg_id"), 10, 64) + if err != nil || msgID <= 0 { + writeError(w, http.StatusBadRequest, "invalid message id") + return 0, 0, false + } + if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member { + writeError(w, http.StatusForbidden, "not a member") + return 0, 0, false + } + belongs, err := h.store.MessageBelongsToConversation(r.Context(), convID, msgID) + if err != nil || !belongs { + writeError(w, http.StatusNotFound, "message not found") + return 0, 0, false + } + return convID, msgID, true +} + +func (h *apiHandler) writeMessageWithReactions(w http.ResponseWriter, r *http.Request, convID, msgID int64) { + msg, err := h.store.GetMessageByID(r.Context(), msgID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + msgs := []messageJSON{toMessageJSON(msg)} + reactions, err := h.store.ReactionsForMessages(r.Context(), []int64{msgID}) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal server error") + return + } + attachReactions(msgs, reactions) + writeJSON(w, http.StatusOK, msgs[0]) +} diff --git a/server/internal/media/local.go b/server/internal/media/local.go new file mode 100644 index 0000000..12bbec7 --- /dev/null +++ b/server/internal/media/local.go @@ -0,0 +1,95 @@ +package media + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" +) + +// Local stores objects as files under a base directory. +type Local struct { + dir string +} + +// NewLocal creates a Local store rooted at dir, creating it if needed. +func NewLocal(dir string) (*Local, error) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, err + } + return &Local{dir: dir}, nil +} + +func (l *Local) resolve(key string) (string, error) { + clean := filepath.Clean(filepath.Join(l.dir, key)) + if !hasPrefix(clean, l.dir) { + return "", errors.New("media: invalid storage key") + } + return clean, nil +} + +// Save writes the stream to disk atomically under the base directory. +func (l *Local) Save(_ context.Context, key string, r io.Reader) error { + path, err := l.resolve(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// Open returns a reader for the object or ErrNotFound. +func (l *Local) Open(_ context.Context, key string) (io.ReadCloser, error) { + path, err := l.resolve(key) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotFound + } + return nil, err + } + return f, nil +} + +// Delete removes the object; missing objects are not an error. +func (l *Local) Delete(_ context.Context, key string) error { + path, err := l.resolve(key) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func hasPrefix(p, dir string) bool { + rel, err := filepath.Rel(dir, p) + if err != nil { + return false + } + return rel != ".." && !startsWithDotDot(rel) +} + +func startsWithDotDot(rel string) bool { + return len(rel) >= 3 && rel[0] == '.' && rel[1] == '.' && (rel[2] == '/' || rel[2] == '\\') +} diff --git a/server/internal/media/storage.go b/server/internal/media/storage.go new file mode 100644 index 0000000..7c6783c --- /dev/null +++ b/server/internal/media/storage.go @@ -0,0 +1,19 @@ +// Package media provides file storage and image thumbnail generation +// for attachments shared in conversations. +package media + +import ( + "context" + "errors" + "io" +) + +// ErrNotFound is returned when a stored object does not exist. +var ErrNotFound = errors.New("media: not found") + +// Storage is an S3-like object store for attachment bytes. +type Storage interface { + Save(ctx context.Context, key string, r io.Reader) error + Open(ctx context.Context, key string) (io.ReadCloser, error) + Delete(ctx context.Context, key string) error +} diff --git a/server/internal/media/storage_test.go b/server/internal/media/storage_test.go new file mode 100644 index 0000000..99e512f --- /dev/null +++ b/server/internal/media/storage_test.go @@ -0,0 +1,73 @@ +package media + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +func TestLocalSaveOpenDelete(t *testing.T) { + ctx := context.Background() + store, err := NewLocal(t.TempDir()) + if err != nil { + t.Fatalf("NewLocal: %v", err) + } + + payload := []byte("hello attachment") + if err := store.Save(ctx, "a/b/file.txt", bytes.NewReader(payload)); err != nil { + t.Fatalf("Save: %v", err) + } + + rc, err := store.Open(ctx, "a/b/file.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + got, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("content = %q, want %q", got, payload) + } + + if err := store.Delete(ctx, "a/b/file.txt"); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := store.Delete(ctx, "a/b/file.txt"); err != nil { + t.Fatalf("Delete idempotent: %v", err) + } + if _, err := store.Open(ctx, "a/b/file.txt"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Open after delete = %v, want ErrNotFound", err) + } +} + +func TestLocalPathTraversalRejected(t *testing.T) { + store, err := NewLocal(t.TempDir()) + if err != nil { + t.Fatalf("NewLocal: %v", err) + } + err = store.Save(context.Background(), "../../etc/evil", strings.NewReader("x")) + if err == nil { + t.Fatal("Save with traversal key = nil, want error") + } +} + +func TestIsImage(t *testing.T) { + cases := map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "application/pdf": false, + "text/plain": false, + } + for ct, want := range cases { + if got := IsImage(ct); got != want { + t.Errorf("IsImage(%q) = %v, want %v", ct, got, want) + } + } +} diff --git a/server/internal/media/thumbnail.go b/server/internal/media/thumbnail.go new file mode 100644 index 0000000..ce41963 --- /dev/null +++ b/server/internal/media/thumbnail.go @@ -0,0 +1,82 @@ +package media + +import ( + "bytes" + "context" + "image" + _ "image/gif" + "image/jpeg" + _ "image/png" + + "golang.org/x/image/draw" + "golang.org/x/image/webp" +) + +const maxUploadSize = 25 << 20 // 25 MiB + +// IsImage reports whether the content type is a decodable image format. +func IsImage(contentType string) bool { + switch contentType { + case "image/jpeg", "image/png", "image/gif", "image/webp": + return true + default: + return false + } +} + +// Generate returns a JPEG thumbnail of at most maxDim on its larger side. +// It returns nil for non-images or when the source is already small enough. +func Generate(ctx context.Context, r *bytes.Reader, contentType string, maxDim int) ([]byte, error) { + if !IsImage(contentType) { + return nil, nil + } + if err := ctx.Err(); err != nil { + return nil, err + } + + src, err := decodeImage(r, contentType) + if err != nil { + return nil, err + } + bounds := src.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w <= maxDim && h <= maxDim { + return nil, nil + } + + scale := float64(maxDim) / float64(max(w, h)) + nw, nh := int(float64(w)*scale), int(float64(h)*scale) + if nw < 1 { + nw = 1 + } + if nh < 1 { + nh = 1 + } + + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil) + + if err := ctx.Err(); err != nil { + return nil, err + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 80}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func decodeImage(r *bytes.Reader, contentType string) (image.Image, error) { + if contentType == "image/webp" { + return webp.Decode(r) + } + img, _, err := image.Decode(r) + return img, err +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/server/internal/media/thumbnail_test.go b/server/internal/media/thumbnail_test.go new file mode 100644 index 0000000..90c86a8 --- /dev/null +++ b/server/internal/media/thumbnail_test.go @@ -0,0 +1,73 @@ +package media + +import ( + "bytes" + "context" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +func testPNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 120, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("png.Encode: %v", err) + } + return buf.Bytes() +} + +func TestGenerateSkipsSmallImage(t *testing.T) { + data := testPNG(t, 64, 64) + out, err := Generate(context.Background(), bytes.NewReader(data), "image/png", 256) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if out != nil { + t.Fatalf("small image produced thumbnail, want nil") + } +} + +func TestGenerateResizesLargeImage(t *testing.T) { + data := testPNG(t, 512, 512) + out, err := Generate(context.Background(), bytes.NewReader(data), "image/png", 256) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if out == nil { + t.Fatal("large image produced nil thumbnail") + } + img, err := jpeg.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("thumbnail is not valid JPEG: %v", err) + } + b := img.Bounds() + if b.Dx() > 256 || b.Dy() > 256 { + t.Fatalf("thumbnail size = %dx%d, want <= 256x256", b.Dx(), b.Dy()) + } +} + +func TestGenerateNonImageReturnsNil(t *testing.T) { + out, err := Generate(context.Background(), bytes.NewReader([]byte("plain")), "text/plain", 256) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if out != nil { + t.Fatal("non-image produced thumbnail, want nil") + } +} + +func TestGenerateCorruptImageFails(t *testing.T) { + _, err := Generate(context.Background(), bytes.NewReader([]byte("not an image")), "image/png", 256) + if err == nil { + t.Fatal("corrupt image: Generate = nil error, want error") + } +} diff --git a/server/internal/platform/config/config.go b/server/internal/platform/config/config.go index 861901a..e16c8db 100644 --- a/server/internal/platform/config/config.go +++ b/server/internal/platform/config/config.go @@ -24,6 +24,8 @@ type Config struct { DatabaseURL string // JWTSecret signs access tokens. Must be set in production. JWTSecret string + // MediaDir is the local disk location for uploaded attachments. + MediaDir string } // Load reads configuration from the environment, falling back to @@ -35,6 +37,7 @@ func Load() (*Config, error) { Environment: fromEnv("MESSENGER_ENV", "development"), DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"), JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"), + MediaDir: fromEnv("MESSENGER_MEDIA_DIR", "./media"), } for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") { diff --git a/server/internal/realtime/events.go b/server/internal/realtime/events.go index e526d19..a5b96cd 100644 --- a/server/internal/realtime/events.go +++ b/server/internal/realtime/events.go @@ -9,10 +9,16 @@ import ( // Event type constants sent over the wire. const ( - EventMessageCreated = "message.created" - EventTyping = "typing" - EventPresence = "presence" - EventRead = "read" + EventMessageCreated = "message.created" + EventTyping = "typing" + EventPresence = "presence" + EventRead = "read" + EventConversationAdded = "conversation.added" + EventConversationRemoved = "conversation.removed" + EventMemberAdded = "member.added" + EventMemberRemoved = "member.removed" + EventReactionAdded = "reaction.added" + EventReactionRemoved = "reaction.removed" ) // Presence status values. @@ -23,12 +29,24 @@ const ( // MessageCreated is published when a message is stored. type MessageCreated struct { - TempID string `json:"temp_id,omitempty"` - ConversationID int64 `json:"conversation_id"` - MessageID int64 `json:"message_id"` - SenderID int64 `json:"sender_id"` - Body string `json:"body"` - CreatedAt time.Time `json:"created_at"` + TempID string `json:"temp_id,omitempty"` + ConversationID int64 `json:"conversation_id"` + MessageID int64 `json:"message_id"` + SenderID int64 `json:"sender_id"` + Body string `json:"body"` + MediaID *int64 `json:"media_id,omitempty"` + Media *MediaInfo `json:"media,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// MediaInfo mirrors attachment metadata delivered with a message event. +type MediaInfo struct { + ID int64 `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + URL string `json:"url"` + ThumbURL string `json:"thumb_url,omitempty"` } // Typing is published when a user starts or stops typing in a conversation. @@ -51,6 +69,25 @@ type Read struct { MessageID int64 `json:"message_id"` } +// ConversationChanged is sent to a user when they gain or lose a conversation. +type ConversationChanged struct { + ConversationID int64 `json:"conversation_id"` +} + +// MemberChanged is published when a user joins or leaves a conversation. +type MemberChanged struct { + ConversationID int64 `json:"conversation_id"` + UserID int64 `json:"user_id"` +} + +// ReactionChanged is published when a reaction is added to or removed from a message. +type ReactionChanged struct { + ConversationID int64 `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID int64 `json:"user_id"` + Emoji string `json:"emoji"` +} + // envelope is the wire format for every message, both inbound and outbound. type envelope struct { Type string `json:"type"` diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index 229576b..8849972 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -84,6 +84,67 @@ func (h *Hub) PublishRead(conversationID, userID, messageID int64) { h.sendToConversation(conversationID, envelope{Type: EventRead, Data: mustMarshal(evt)}, userID) } +// Subscribe adds a user to a conversation's subscriber set so they receive its +// events without reconnecting (used when a member is added to a group). +func (h *Hub) Subscribe(userID, conversationID int64) { + h.mu.Lock() + defer h.mu.Unlock() + if h.subs[conversationID] == nil { + h.subs[conversationID] = make(map[int64]struct{}) + } + h.subs[conversationID][userID] = struct{}{} +} + +// Unsubscribe removes a user from a conversation's subscriber set. +func (h *Hub) Unsubscribe(userID, conversationID int64) { + h.mu.Lock() + defer h.mu.Unlock() + users := h.subs[conversationID] + delete(users, userID) + if len(users) == 0 { + delete(h.subs, conversationID) + } +} + +// SendToUser delivers an event to every connection of a single user. +func (h *Hub) SendToUser(userID int64, evtType string, data any) { + payload, err := json.Marshal(envelope{Type: evtType, Data: mustMarshal(data)}) + if err != nil { + slog.Error("ws: marshal direct event", "error", err) + return + } + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.conns[userID] { + c.enqueueRaw(payload) + } +} + +// PublishMemberChanged tells a conversation's members that someone joined or left. +func (h *Hub) PublishMemberChanged(conversationID, userID int64, added bool) { + evtType := EventMemberAdded + if !added { + evtType = EventMemberRemoved + } + evt := MemberChanged{ConversationID: conversationID, UserID: userID} + h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0) +} + +// PublishReactionChanged tells a conversation's members that a reaction changed. +func (h *Hub) PublishReactionChanged(conversationID, messageID, userID int64, emoji string, added bool) { + evtType := EventReactionAdded + if !added { + evtType = EventReactionRemoved + } + evt := ReactionChanged{ + ConversationID: conversationID, + MessageID: messageID, + UserID: userID, + Emoji: emoji, + } + h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0) +} + func (h *Hub) register(c *client, conversationIDs []int64) { h.mu.Lock() defer h.mu.Unlock() diff --git a/server/internal/server/server.go b/server/internal/server/server.go index 8a01ae5..707c08d 100644 --- a/server/internal/server/server.go +++ b/server/internal/server/server.go @@ -10,13 +10,14 @@ import ( "messenger/internal/api" "messenger/internal/auth" + "messenger/internal/media" "messenger/internal/platform/config" "messenger/internal/realtime" "messenger/internal/store" ) // New builds an *http.Server with all routes and middleware applied. -func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenManager, hub *realtime.Hub) *http.Server { +func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, ms media.Storage) *http.Server { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -32,7 +33,7 @@ func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenMa static = spaHandler{staticDir: cfg.StaticDir, log: log} } - mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, static)) + mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, ms, static)) handler := logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux))) diff --git a/server/internal/store/conversations.go b/server/internal/store/conversations.go index e2a58b5..b8649d1 100644 --- a/server/internal/store/conversations.go +++ b/server/internal/store/conversations.go @@ -14,13 +14,13 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation // Check existing DM var conv Conversation err := s.pool.QueryRow(ctx, ` - SELECT c.id, c.type, c.created_at + SELECT c.id, c.type, c.name, c.avatar, c.created_at FROM conversations c JOIN conversation_members m1 ON m1.conversation_id=c.id AND m1.user_id=$1 JOIN conversation_members m2 ON m2.conversation_id=c.id AND m2.user_id=$2 WHERE c.type='dm' LIMIT 1 - `, userA, userB).Scan(&conv.ID, &conv.Type, &conv.CreatedAt) + `, userA, userB).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt) if err == nil { return &conv, nil } @@ -42,8 +42,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation err = tx.QueryRow(ctx, ` INSERT INTO conversations(type) VALUES('dm') - RETURNING id, type, created_at - `).Scan(&conv.ID, &conv.Type, &conv.CreatedAt) + RETURNING id, type, name, avatar, created_at + `).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt) if err != nil { return nil, err } @@ -68,8 +68,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation func (s *Store) GetConversationByID(ctx context.Context, id int64) (*Conversation, error) { var conv Conversation err := s.pool.QueryRow(ctx, ` - SELECT id, type, created_at FROM conversations WHERE id=$1 - `, id).Scan(&conv.ID, &conv.Type, &conv.CreatedAt) + SELECT id, type, name, avatar, created_at FROM conversations WHERE id=$1 + `, id).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -118,7 +118,7 @@ func (s *Store) ConversationIDsForUser(ctx context.Context, userID int64) ([]int // ListConversationsForUser returns summaries of conversations for a user. func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]ConversationSummary, error) { rows, err := s.pool.Query(ctx, ` - SELECT c.id, c.type, c.created_at, + SELECT c.id, c.type, c.name, c.avatar, c.created_at, m.id, m.conversation_id, m.sender_id, m.body, m.created_at, m.edited_at, m.deleted_at, uc.unread_count FROM conversation_members cm @@ -156,7 +156,7 @@ func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]C msgDeleted *time.Time ) if err := rows.Scan( - &cs.ID, &cs.Type, &cs.CreatedAt, + &cs.ID, &cs.Type, &cs.Name, &cs.Avatar, &cs.CreatedAt, &msgID, &msgConvID, &msgSender, &msgBody, &msgCreated, &msgEdited, &msgDeleted, &cs.UnreadCount, ); err != nil { diff --git a/server/internal/store/groups.go b/server/internal/store/groups.go new file mode 100644 index 0000000..5285573 --- /dev/null +++ b/server/internal/store/groups.go @@ -0,0 +1,121 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +// CreateGroup creates a group conversation with the creator as owner and +// the given members added, returning the created conversation. +func (s *Store) CreateGroup(ctx context.Context, creatorID int64, name string, memberIDs []int64) (*Conversation, error) { + conn, err := s.pool.Acquire(ctx) + if err != nil { + return nil, err + } + defer conn.Release() + + tx, err := conn.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var conv Conversation + err = tx.QueryRow(ctx, ` + INSERT INTO conversations(type, name) VALUES('group', $1) + RETURNING id, type, name, avatar, created_at + `, name).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt) + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO conversation_members(conversation_id, user_id, role) + VALUES($1, $2, 'owner') + `, conv.ID, creatorID); err != nil { + return nil, err + } + + seen := map[int64]struct{}{creatorID: {}} + for _, uid := range memberIDs { + if _, ok := seen[uid]; ok { + continue + } + seen[uid] = struct{}{} + if _, err := tx.Exec(ctx, ` + INSERT INTO conversation_members(conversation_id, user_id, role) + VALUES($1, $2, 'member') + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, conv.ID, uid); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &conv, nil +} + +// AddMember adds a user to a conversation; it is a no-op if already a member. +func (s *Store) AddMember(ctx context.Context, conversationID, userID int64) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO conversation_members(conversation_id, user_id, role) + VALUES($1, $2, 'member') + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, conversationID, userID) + return err +} + +// RemoveMember removes a user from a conversation; it is a no-op if absent. +func (s *Store) RemoveMember(ctx context.Context, conversationID, userID int64) error { + _, err := s.pool.Exec(ctx, ` + DELETE FROM conversation_members WHERE conversation_id=$1 AND user_id=$2 + `, conversationID, userID) + return err +} + +// MemberRole returns the role of a user in a conversation, or ErrNotFound. +func (s *Store) MemberRole(ctx context.Context, conversationID, userID int64) (string, error) { + var role string + err := s.pool.QueryRow(ctx, ` + SELECT role FROM conversation_members WHERE conversation_id=$1 AND user_id=$2 + `, conversationID, userID).Scan(&role) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + return role, nil +} + +// ListMembers returns the members of a conversation with their profiles. +func (s *Store) ListMembers(ctx context.Context, conversationID int64) ([]MemberInfo, error) { + rows, err := s.pool.Query(ctx, ` + SELECT u.id, u.username, u.display_name, u.avatar, u.created_at, cm.role, cm.joined_at + FROM conversation_members cm + JOIN users u ON u.id = cm.user_id + WHERE cm.conversation_id = $1 + ORDER BY cm.joined_at, u.id + `, conversationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []MemberInfo + for rows.Next() { + var m MemberInfo + if err := rows.Scan( + &m.User.ID, &m.User.Username, &m.User.DisplayName, &m.User.Avatar, &m.User.CreatedAt, + &m.Role, &m.JoinedAt, + ); err != nil { + return nil, err + } + members = append(members, m) + } + return members, nil +} diff --git a/server/internal/store/integration_test.go b/server/internal/store/integration_test.go index 83da570..a8a0567 100644 --- a/server/internal/store/integration_test.go +++ b/server/internal/store/integration_test.go @@ -97,7 +97,7 @@ func TestIntegration(t *testing.T) { t.Fatalf("ListConversationsForUser: %v, %v", sums, err) } - msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello") + msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello", nil) if err != nil { t.Fatalf("InsertMessage: %v", err) } @@ -111,4 +111,74 @@ func TestIntegration(t *testing.T) { if err := db.UpdateLastRead(ctx, conv.ID, u2.ID); err != nil { t.Fatalf("UpdateLastRead: %v", err) } + + // Groups + u3, err := db.CreateUser(ctx, "user3", "User Three", "hash3") + if err != nil { + t.Fatalf("CreateUser u3: %v", err) + } + group, err := db.CreateGroup(ctx, u1.ID, "Family", []int64{u2.ID, u1.ID}) + if err != nil { + t.Fatalf("CreateGroup: %v", err) + } + if group.Type != "group" || group.Name != "Family" { + t.Fatalf("CreateGroup result = %+v", group) + } + members, err := db.ListMembers(ctx, group.ID) + if err != nil { + t.Fatalf("ListMembers: %v", err) + } + if len(members) != 2 { + t.Fatalf("ListMembers len = %d, want 2", len(members)) + } + role, err := db.MemberRole(ctx, group.ID, u1.ID) + if err != nil || role != "owner" { + t.Fatalf("MemberRole u1 = %q, %v; want owner", role, err) + } + if err := db.AddMember(ctx, group.ID, u3.ID); err != nil { + t.Fatalf("AddMember: %v", err) + } + if err := db.AddMember(ctx, group.ID, u3.ID); err != nil { + t.Fatalf("AddMember idempotent: %v", err) + } + members, _ = db.ListMembers(ctx, group.ID) + if len(members) != 3 { + t.Fatalf("ListMembers after add = %d, want 3", len(members)) + } + if err := db.RemoveMember(ctx, group.ID, u3.ID); err != nil { + t.Fatalf("RemoveMember: %v", err) + } + members, _ = db.ListMembers(ctx, group.ID) + if len(members) != 2 { + t.Fatalf("ListMembers after remove = %d, want 2", len(members)) + } + + // Reactions + if err := db.AddReaction(ctx, msg.ID, u2.ID, "👍"); err != nil { + t.Fatalf("AddReaction: %v", err) + } + if err := db.AddReaction(ctx, msg.ID, u2.ID, "👍"); err != nil { + t.Fatalf("AddReaction idempotent: %v", err) + } + if err := db.AddReaction(ctx, msg.ID, u1.ID, "❤️"); err != nil { + t.Fatalf("AddReaction u1: %v", err) + } + reactions, err := db.ReactionsForMessages(ctx, []int64{msg.ID}) + if err != nil { + t.Fatalf("ReactionsForMessages: %v", err) + } + if len(reactions) != 2 { + t.Fatalf("ReactionsForMessages len = %d, want 2", len(reactions)) + } + if err := db.RemoveReaction(ctx, msg.ID, u2.ID, "👍"); err != nil { + t.Fatalf("RemoveReaction: %v", err) + } + reactions, _ = db.ReactionsForMessages(ctx, []int64{msg.ID}) + if len(reactions) != 1 { + t.Fatalf("ReactionsForMessages after remove = %d, want 1", len(reactions)) + } + belongs, err := db.MessageBelongsToConversation(ctx, conv.ID, msg.ID) + if err != nil || !belongs { + t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err) + } } diff --git a/server/internal/store/media.go b/server/internal/store/media.go new file mode 100644 index 0000000..82e3b7f --- /dev/null +++ b/server/internal/store/media.go @@ -0,0 +1,62 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// Media stores metadata of an uploaded file. The file bytes live in the +// media storage backend (see internal/media), referenced by StorageKey. +type Media struct { + ID int64 + UploaderID int64 + Filename string + ContentType string + Size int64 + StorageKey string + ThumbKey string + CreatedAt time.Time +} + +// CreateMedia inserts a media record and returns it with its ID. +func (s *Store) CreateMedia(ctx context.Context, m Media) (*Media, error) { + err := s.pool.QueryRow(ctx, ` + INSERT INTO media(uploader_id, filename, content_type, size, storage_key, thumb_key) + VALUES($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + `, m.UploaderID, m.Filename, m.ContentType, m.Size, m.StorageKey, nullableString(m.ThumbKey)).Scan(&m.ID, &m.CreatedAt) + if err != nil { + return nil, err + } + return &m, nil +} + +// GetMediaByID retrieves a media record or returns ErrNotFound. +func (s *Store) GetMediaByID(ctx context.Context, id int64) (*Media, error) { + var m Media + var thumbKey *string + err := s.pool.QueryRow(ctx, ` + SELECT id, uploader_id, filename, content_type, size, storage_key, thumb_key, created_at + FROM media WHERE id=$1 + `, id).Scan(&m.ID, &m.UploaderID, &m.Filename, &m.ContentType, &m.Size, &m.StorageKey, &thumbKey, &m.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if thumbKey != nil { + m.ThumbKey = *thumbKey + } + return &m, nil +} + +func nullableString(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/server/internal/store/messages.go b/server/internal/store/messages.go index 427ccc4..465f073 100644 --- a/server/internal/store/messages.go +++ b/server/internal/store/messages.go @@ -8,14 +8,15 @@ import ( ) // InsertMessage creates a new message in a conversation and returns it. -func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string) (*Message, error) { +// mediaID, when non-nil, attaches a previously uploaded media object. +func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string, mediaID *int64) (*Message, error) { var m Message err := s.pool.QueryRow(ctx, ` - INSERT INTO messages(conversation_id, sender_id, body) - VALUES($1, $2, $3) - RETURNING id, conversation_id, sender_id, body, created_at, edited_at, deleted_at - `, conversationID, senderID, body).Scan( - &m.ID, &m.ConversationID, &m.SenderID, &m.Body, + INSERT INTO messages(conversation_id, sender_id, body, media_id) + VALUES($1, $2, $3, $4) + RETURNING id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at + `, conversationID, senderID, body, mediaID).Scan( + &m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID, &m.CreatedAt, &m.EditedAt, &m.DeletedAt, ) if err != nil { @@ -28,10 +29,10 @@ func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int6 func (s *Store) GetMessageByID(ctx context.Context, id int64) (*Message, error) { var m Message err := s.pool.QueryRow(ctx, ` - SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at + SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at FROM messages WHERE id=$1 `, id).Scan( - &m.ID, &m.ConversationID, &m.SenderID, &m.Body, + &m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID, &m.CreatedAt, &m.EditedAt, &m.DeletedAt, ) if err != nil { @@ -50,14 +51,14 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i var err error if before > 0 { rows, err = s.pool.Query(ctx, ` - SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at + SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at FROM messages WHERE conversation_id=$1 AND id<$2 ORDER BY id DESC LIMIT $3 `, conversationID, before, limit) } else { rows, err = s.pool.Query(ctx, ` - SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at + SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at FROM messages WHERE conversation_id=$1 ORDER BY id DESC LIMIT $2 @@ -72,7 +73,7 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i for rows.Next() { var m Message if err := rows.Scan( - &m.ID, &m.ConversationID, &m.SenderID, &m.Body, + &m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID, &m.CreatedAt, &m.EditedAt, &m.DeletedAt, ); err != nil { return nil, err diff --git a/server/internal/store/migrations/0002_groups.sql b/server/internal/store/migrations/0002_groups.sql new file mode 100644 index 0000000..f665d53 --- /dev/null +++ b/server/internal/store/migrations/0002_groups.sql @@ -0,0 +1,5 @@ +-- 0002_groups.sql +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT ''; +ALTER TABLE conversation_members ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'member'; +CREATE INDEX IF NOT EXISTS idx_members_conversation ON conversation_members(conversation_id); diff --git a/server/internal/store/migrations/0003_reactions.sql b/server/internal/store/migrations/0003_reactions.sql new file mode 100644 index 0000000..d060139 --- /dev/null +++ b/server/internal/store/migrations/0003_reactions.sql @@ -0,0 +1,9 @@ +-- 0003_reactions.sql +CREATE TABLE IF NOT EXISTS message_reactions ( + message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (message_id, user_id, emoji) +); +CREATE INDEX IF NOT EXISTS idx_reactions_message ON message_reactions(message_id); \ No newline at end of file diff --git a/server/internal/store/migrations/0004_media.sql b/server/internal/store/migrations/0004_media.sql new file mode 100644 index 0000000..2bc3531 --- /dev/null +++ b/server/internal/store/migrations/0004_media.sql @@ -0,0 +1,14 @@ +-- 0004_media.sql +CREATE TABLE IF NOT EXISTS media ( + id BIGSERIAL PRIMARY KEY, + uploader_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size BIGINT NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + thumb_key TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_media_uploader ON media(uploader_id); + +ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_id BIGINT REFERENCES media(id) ON DELETE SET NULL; \ No newline at end of file diff --git a/server/internal/store/reactions.go b/server/internal/store/reactions.go new file mode 100644 index 0000000..44939c8 --- /dev/null +++ b/server/internal/store/reactions.go @@ -0,0 +1,71 @@ +package store + +import ( + "context" +) + +// Reaction represents a single emoji reaction on a message. +type Reaction struct { + MessageID int64 + UserID int64 + Emoji string +} + +// AddReaction adds a reaction to a message; it is a no-op if it already exists. +func (s *Store) AddReaction(ctx context.Context, messageID, userID int64, emoji string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO message_reactions(message_id, user_id, emoji) + VALUES($1, $2, $3) + ON CONFLICT (message_id, user_id, emoji) DO NOTHING + `, messageID, userID, emoji) + return err +} + +// RemoveReaction removes a reaction from a message; it is a no-op if absent. +func (s *Store) RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error { + _, err := s.pool.Exec(ctx, ` + DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3 + `, messageID, userID, emoji) + return err +} + +// ReactionsForMessages returns all reactions for the given message IDs. +func (s *Store) ReactionsForMessages(ctx context.Context, messageIDs []int64) ([]Reaction, error) { + if len(messageIDs) == 0 { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT message_id, user_id, emoji + FROM message_reactions + WHERE message_id = ANY($1) + ORDER BY message_id, created_at + `, messageIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + var reactions []Reaction + for rows.Next() { + var r Reaction + if err := rows.Scan(&r.MessageID, &r.UserID, &r.Emoji); err != nil { + return nil, err + } + reactions = append(reactions, r) + } + return reactions, nil +} + +// MessageBelongsToConversation reports whether a message belongs to a conversation. +func (s *Store) MessageBelongsToConversation(ctx context.Context, conversationID, messageID int64) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM messages WHERE id=$1 AND conversation_id=$2 + ) + `, messageID, conversationID).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index f105282..1af280b 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -42,9 +42,18 @@ type Session struct { type Conversation struct { ID int64 Type string + Name string + Avatar string CreatedAt time.Time } +// MemberInfo describes a conversation participant together with their profile. +type MemberInfo struct { + User User + Role string + JoinedAt time.Time +} + // ConversationSummary provides a conversation with its last message and unread count. type ConversationSummary struct { Conversation @@ -58,6 +67,7 @@ type Message struct { ConversationID int64 SenderID int64 Body string + MediaID *int64 CreatedAt time.Time EditedAt *time.Time DeletedAt *time.Time diff --git a/web/src/components/Avatar.tsx b/web/src/components/Avatar.tsx index 7f15bc6..b7200b5 100644 --- a/web/src/components/Avatar.tsx +++ b/web/src/components/Avatar.tsx @@ -1,4 +1,5 @@ import { avatarColor, initials } from '../lib/format' +import { IconUsers } from './icons' type AvatarSize = 'sm' | 'md' | 'lg' | 'xl' @@ -38,3 +39,26 @@ export default function Avatar({ name, seed, size = 'md', online = false, classN ) } + +interface GroupAvatarProps { + name: string + seed: number | string + size?: AvatarSize + className?: string +} + +export function GroupAvatar({ name, seed, size = 'md', className = '' }: GroupAvatarProps) { + const background = avatarColor(seed) + return ( + + + + ) +} diff --git a/web/src/components/ChatWindow.tsx b/web/src/components/ChatWindow.tsx index 3d4b3c1..0edadac 100644 --- a/web/src/components/ChatWindow.tsx +++ b/web/src/components/ChatWindow.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import Avatar from './Avatar' +import Avatar, { GroupAvatar } from './Avatar' import MessageList from './MessageList' -import { IconArrowLeft, IconChat, IconSend } from './icons' -import { peerForConversation, userTitle } from '../lib/format' -import type { ClientMessage, Conversation, User } from '../lib/types' +import MembersDialog from './MembersDialog' +import { IconArrowLeft, IconChat, IconClose, IconFile, IconPaperclip, IconSend, IconUsers } from './icons' +import { api } from '../lib/api' +import { formatBytes, peerForConversation, userTitle } from '../lib/format' +import type { ClientMessage, Conversation, Media, Member, User } from '../lib/types' interface ChatWindowProps { className?: string @@ -18,11 +20,15 @@ interface ChatWindowProps { loadingOlder: boolean hasMore: boolean error: string | null + membersVersion: number onBack: () => void - onSend: (conversationId: number, body: string) => void + onSend: (conversationId: number, body: string, media: Media | null) => void onTyping: (conversationId: number, isTyping: boolean) => void onLoadOlder: () => void onRetry: (message: ClientMessage) => void + onReact: (message: ClientMessage, emoji: string) => void + onMembersChanged: () => void + onMembersLoaded: (members: Member[]) => void } function TypingDots() { @@ -39,14 +45,26 @@ function TypingDots() { ) } +function membersLabel(count: number): string { + const mod10 = count % 10 + const mod100 = count % 100 + if (mod10 === 1 && mod100 !== 11) return `${count} участник` + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} участника` + return `${count} участников` +} + interface ComposerProps { - onSend: (body: string) => void + onSend: (body: string, media: Media | null) => void onTyping: (isTyping: boolean) => void } function Composer({ onSend, onTyping }: ComposerProps) { const [value, setValue] = useState('') + const [attachment, setAttachment] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) const textareaRef = useRef(null) + const fileInputRef = useRef(null) const typingRef = useRef(false) const typingTimerRef = useRef(null) @@ -72,15 +90,17 @@ function Composer({ onSend, onTyping }: ComposerProps) { const submit = useCallback(() => { const trimmed = value.trim() - if (!trimmed) return - onSend(trimmed) + if ((!trimmed && !attachment) || uploading) return + onSend(trimmed, attachment) setValue('') + setAttachment(null) + setUploadError(null) stopTyping() requestAnimationFrame(() => { resize() textareaRef.current?.focus() }) - }, [onSend, resize, stopTyping, value]) + }, [attachment, onSend, resize, stopTyping, uploading, value]) const handleChange = (next: string) => { setValue(next) @@ -96,9 +116,81 @@ function Composer({ onSend, onTyping }: ComposerProps) { }, 2000) } + const handleFile = async (file: File) => { + setUploading(true) + setUploadError(null) + try { + const media = await api.uploadMedia(file) + setAttachment(media) + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Не удалось загрузить файл') + } finally { + setUploading(false) + } + } + + const isImage = attachment ? attachment.content_type.startsWith('image/') : false + const canSend = (value.trim().length > 0 || attachment !== null) && !uploading + return (
+ {attachment && ( +
+ {isImage ? ( + {attachment.filename} + ) : ( + + + + )} + + {attachment.filename} + {formatBytes(attachment.size)} + + +
+ )} + + {uploadError &&

{uploadError}

} +
+ { + const file = event.target.files?.[0] + event.target.value = '' + if (file) void handleFile(file) + }} + /> + + @@ -120,7 +212,7 @@ function Composer({ onSend, onTyping }: ComposerProps) { - {peer ? ( - + {isGroup ? ( + + ) : peer ? ( + ) : ( # @@ -199,12 +330,23 @@ export default function ChatWindow({ )}
-

{peerName}

+

{title}

- {peerIsTyping && } - {subtitle} + {peerIsTyping && !isGroup && } + {subtitle}

+ + {isGroup && ( + + )} onSend(conversation.id, body)} + onSend={(body, media) => onSend(conversation.id, body, media)} onTyping={(isTyping) => onTyping(conversation.id, isTyping)} /> + + {isGroup && membersOpen && ( + setMembersOpen(false)} + onChanged={onMembersChanged} + /> + )} ) } diff --git a/web/src/components/MembersDialog.tsx b/web/src/components/MembersDialog.tsx new file mode 100644 index 0000000..3de2797 --- /dev/null +++ b/web/src/components/MembersDialog.tsx @@ -0,0 +1,240 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import Avatar from './Avatar' +import { IconClose, IconSearch, IconTrash, IconUserPlus } from './icons' +import { api } from '../lib/api' +import { userTitle } from '../lib/format' +import type { Member, User } from '../lib/types' + +interface MembersDialogProps { + conversationId: number + currentUser: User + members: Member[] + loading: boolean + onClose: () => void + onChanged: () => void +} + +export default function MembersDialog({ + conversationId, + currentUser, + members, + loading, + onClose, + onChanged, +}: MembersDialogProps) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [resultQuery, setResultQuery] = useState('') + const [busyId, setBusyId] = useState(null) + const [error, setError] = useState(null) + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + useEffect(() => { + const term = query.trim() + if (term.length === 0) return + let cancelled = false + const timer = window.setTimeout(() => { + api + .searchUsers(term) + .then((users) => { + if (cancelled) return + setResults(users) + setResultQuery(term) + }) + .catch(() => { + if (cancelled) return + setResults([]) + setResultQuery(term) + }) + }, 250) + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [query]) + + const memberIds = useMemo(() => new Set(members.map((member) => member.user.id)), [members]) + const isOwner = members.some((member) => member.user.id === currentUser.id && member.role === 'owner') + const trimmed = query.trim() + const searching = trimmed.length > 0 && resultQuery !== trimmed + const candidates = results.filter((user) => !memberIds.has(user.id)) + + const runAction = async (userId: number, action: () => Promise) => { + setBusyId(userId) + setError(null) + try { + await action() + onChanged() + } catch (err) { + setError(err instanceof Error ? err.message : 'Не удалось выполнить действие') + } finally { + setBusyId(null) + } + } + + const handleAdd = (user: User) => + runAction(user.id, async () => { + await api.addMember(conversationId, user.id) + setQuery('') + setResults([]) + setResultQuery('') + }) + + const handleRemove = (member: Member) => + runAction(member.user.id, async () => { + await api.removeMember(conversationId, member.user.id) + }) + + return ( +
+ +
+ +
+
+ + + setQuery(event.target.value)} + placeholder="Добавить участника: имя или @username" + className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20" + /> +
+ {error &&

{error}

} +
+ +
+ {trimmed.length > 0 && ( +
+

Найдены

+ {searching ? ( +

Поиск…

+ ) : candidates.length === 0 ? ( +

Некого добавить

+ ) : ( +
    + {candidates.map((user) => { + const busy = busyId === user.id + return ( +
  • + +
    +

    {userTitle(user)}

    +

    @{user.username}

    +
    + +
  • + ) + })} +
+ )} +
+ )} + +

В группе

+ {loading ? ( +
+ {[0, 1, 2].map((row) => ( +
+ + +
+ ))} +
+ ) : ( +
    + {members.map((member) => { + const isSelf = member.user.id === currentUser.id + const canRemove = member.role !== 'owner' && (isOwner || isSelf) + const busy = busyId === member.user.id + return ( +
  • + +
    +

    + {userTitle(member.user)} + {member.role === 'owner' && ( + + владелец + + )} + {isSelf && member.role !== 'owner' && ( + вы + )} +

    +

    @{member.user.username}

    +
    + {canRemove && ( + + )} +
  • + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/web/src/components/MessageBubble.tsx b/web/src/components/MessageBubble.tsx index ff7861b..d8baa5c 100644 --- a/web/src/components/MessageBubble.tsx +++ b/web/src/components/MessageBubble.tsx @@ -1,7 +1,8 @@ +import { useEffect, useMemo, useState } from 'react' import Avatar from './Avatar' -import { IconAlert, IconCheck, IconCheckCheck } from './icons' -import { formatTime, userTitle } from '../lib/format' -import type { ClientMessage, User } from '../lib/types' +import { IconAlert, IconCheck, IconCheckCheck, IconFile, IconSmile } from './icons' +import { formatBytes, formatTime, userTitle } from '../lib/format' +import type { ClientMessage, Media, User } from '../lib/types' interface MessageBubbleProps { message: ClientMessage @@ -9,17 +10,93 @@ interface MessageBubbleProps { sender: User | null showAvatar: boolean showName: boolean + currentUserId: number onRetry: (message: ClientMessage) => void + onReact: (message: ClientMessage, emoji: string) => void } -export default function MessageBubble({ message, isOwn, sender, showAvatar, showName, onRetry }: MessageBubbleProps) { +const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥'] as const + +function MediaImage({ media }: { media: Media }) { + const [src, setSrc] = useState(media.thumb_url ?? media.url) + + useEffect(() => { + if (!media.thumb_url || media.thumb_url === media.url) return + const image = new Image() + image.src = media.url + image.onload = () => setSrc(media.url) + return () => { + image.onload = null + } + }, [media.thumb_url, media.url]) + + return ( + + {media.filename} + + ) +} + +function MediaFile({ media }: { media: Media }) { + return ( + + + + + + {media.filename} + {formatBytes(media.size)} + + + ) +} + +export default function MessageBubble({ + message, + isOwn, + sender, + showAvatar, + showName, + currentUserId, + onRetry, + onReact, +}: MessageBubbleProps) { const name = sender ? userTitle(sender) : `Пользователь #${message.sender_id}` const isSending = message.status === 'sending' const isError = message.status === 'error' + const grouped = useMemo(() => { + const map = new Map() + for (const reaction of message.reactions ?? []) { + const users = map.get(reaction.emoji) ?? [] + users.push(reaction.user_id) + map.set(reaction.emoji, users) + } + return [...map.entries()] + }, [message.reactions]) + + const media = message.media ?? null + const isImage = media ? media.content_type.startsWith('image/') : false + return (
{!isOwn && (showAvatar ? ( @@ -29,53 +106,105 @@ export default function MessageBubble({ message, isOwn, sender, showAvatar, show ))}
- {!isOwn && showName && ( - {name} - )} - -
-

{message.body}

+ {!isOwn && showName && {name}} +
- {isOwn && ( -
+ {grouped.length > 0 && ( +
+ {grouped.map(([emoji, users]) => { + const mine = users.includes(currentUserId) + return ( + + ) + })} +
+ )} + {isError && Не отправлено}
diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 9049bd7..a0a9a1c 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -13,6 +13,7 @@ interface MessageListProps { error: string | null onLoadOlder: () => void onRetry: (message: ClientMessage) => void + onReact: (message: ClientMessage, emoji: string) => void } const GROUP_WINDOW_MINUTES = 5 @@ -27,6 +28,7 @@ export default function MessageList({ error, onLoadOlder, onRetry, + onReact, }: MessageListProps) { const scrollRef = useRef(null) const firstIdRef = useRef(null) @@ -135,7 +137,9 @@ export default function MessageList({ sender={directory.get(message.sender_id) ?? null} showAvatar={newGroup} showName={newGroup} + currentUserId={currentUser.id} onRetry={onRetry} + onReact={onReact} />
) diff --git a/web/src/components/NewChatDialog.tsx b/web/src/components/NewChatDialog.tsx index 8e80730..370f785 100644 --- a/web/src/components/NewChatDialog.tsx +++ b/web/src/components/NewChatDialog.tsx @@ -1,17 +1,26 @@ import { useEffect, useMemo, useRef, useState } from 'react' import Avatar from './Avatar' -import { IconClose, IconSearch, IconUserPlus } from './icons' +import { IconClose, IconSearch, IconUserPlus, IconUsers } from './icons' import { api } from '../lib/api' import { userTitle } from '../lib/format' import type { Contact, User } from '../lib/types' +type DialogMode = 'dm' | 'group' + interface NewChatDialogProps { onClose: () => void onStartChat: (user: User) => Promise + onCreateGroup: (name: string, memberIds: number[]) => Promise onContactsChanged: () => void } -export default function NewChatDialog({ onClose, onStartChat, onContactsChanged }: NewChatDialogProps) { +export default function NewChatDialog({ + onClose, + onStartChat, + onCreateGroup, + onContactsChanged, +}: NewChatDialogProps) { + const [mode, setMode] = useState('dm') const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [resultQuery, setResultQuery] = useState('') @@ -19,6 +28,9 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged const [loadingContacts, setLoadingContacts] = useState(true) const [error, setError] = useState(null) const [busyId, setBusyId] = useState(null) + const [groupName, setGroupName] = useState('') + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [creating, setCreating] = useState(false) const inputRef = useRef(null) useEffect(() => { @@ -106,6 +118,32 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged onContactsChanged() }) + const toggleMember = (userId: number) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(userId)) next.delete(userId) + else next.add(userId) + return next + }) + } + + const handleCreateGroup = async () => { + const name = groupName.trim() + if (!name || selectedIds.size === 0) return + setCreating(true) + setError(null) + try { + await onCreateGroup(name, [...selectedIds]) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Не удалось создать группу') + } finally { + setCreating(false) + } + } + + const canCreate = groupName.trim().length > 0 && selectedIds.size > 0 && !creating + return (
+ ))} + + + {mode === 'group' && ( +
+ + + setGroupName(event.target.value)} + placeholder="Название группы" + maxLength={80} + className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20" + /> +
+ )} +
) })} )} + + {mode === 'group' && ( +
+ + Выбрано: {selectedIds.size} + + +
+ )} ) diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index a779e6d..221f840 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import Avatar from './Avatar' +import Avatar, { GroupAvatar } from './Avatar' import { IconLogout, IconPlus, IconSearch } from './icons' import { formatListTime, peerForConversation, userTitle } from '../lib/format' import type { Conversation, User } from '../lib/types' @@ -21,9 +21,15 @@ interface SidebarProps { onLogout: () => void } -function previewOf(conversation: Conversation, currentUserId: number): string { +function previewOf(conversation: Conversation, currentUserId: number, directory: Map): string { const last = conversation.last_message if (!last) return 'Нет сообщений' + if (last.media && !last.body) return last.sender_id === currentUserId ? 'Вы: вложение' : 'Вложение' + if (conversation.type === 'group' && last.sender_id !== currentUserId) { + const sender = directory.get(last.sender_id) + const prefix = sender ? `${userTitle(sender)}: ` : '' + return `${prefix}${last.body}` + } const prefix = last.sender_id === currentUserId ? 'Вы: ' : '' return `${prefix}${last.body}` } @@ -50,9 +56,20 @@ export default function Sidebar({ const term = query.trim().toLowerCase() return conversations .map((conversation) => { - const peer = peerForConversation(conversation, peers, directory, currentUser.id) - const title = peer ? userTitle(peer) : `Чат #${conversation.id}` - return { conversation, peer, title, preview: previewOf(conversation, currentUser.id) } + const isGroup = conversation.type === 'group' + const peer = isGroup ? null : peerForConversation(conversation, peers, directory, currentUser.id) + const title = isGroup + ? conversation.name || `Группа #${conversation.id}` + : peer + ? userTitle(peer) + : `Чат #${conversation.id}` + return { + conversation, + peer, + isGroup, + title, + preview: previewOf(conversation, currentUser.id, directory), + } }) .filter((item) => !term || item.title.toLowerCase().includes(term) || item.preview.toLowerCase().includes(term)) }, [conversations, query, peers, directory, currentUser.id]) @@ -152,7 +169,7 @@ export default function Sidebar({
    {!loading && !error && - items.map(({ conversation, peer, title, preview }) => { + items.map(({ conversation, peer, isGroup, title, preview }) => { const selected = conversation.id === selectedId const isTyping = typing[conversation.id] === true const unread = conversation.unread_count ?? 0 @@ -168,12 +185,16 @@ export default function Sidebar({ selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70' }`} > - + {isGroup ? ( + + ) : ( + + )} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 652981e..44766c0 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -108,3 +108,61 @@ export function IconUserPlus({ className }: IconProps) { ) } + +export function IconUsers({ className }: IconProps) { + return ( + + ) +} + +export function IconPaperclip({ className }: IconProps) { + return ( + + ) +} + +export function IconFile({ className }: IconProps) { + return ( + + ) +} + +export function IconTrash({ className }: IconProps) { + return ( + + ) +} + +export function IconSmile({ className }: IconProps) { + return ( + + ) +} diff --git a/web/src/hooks/useConversations.ts b/web/src/hooks/useConversations.ts index 536c64a..45511cc 100644 --- a/web/src/hooks/useConversations.ts +++ b/web/src/hooks/useConversations.ts @@ -18,6 +18,7 @@ export interface UseConversationsResult { error: string | null reload: () => Promise add: (conversation: Conversation) => void + remove: (id: number) => void patch: (id: number, updater: (conversation: Conversation) => Conversation) => void } @@ -71,5 +72,9 @@ export function useConversations(enabled = true): UseConversationsResult { setConversations((prev) => sortConversations(prev.map((item) => (item.id === id ? updater(item) : item)))) }, []) - return { conversations, loading, error, reload, add, patch } + const remove = useCallback((id: number) => { + setConversations((prev) => prev.filter((item) => item.id !== id)) + }, []) + + return { conversations, loading, error, reload, add, remove, patch } } diff --git a/web/src/hooks/useMessages.ts b/web/src/hooks/useMessages.ts index 78e5ea5..9a34bb2 100644 --- a/web/src/hooks/useMessages.ts +++ b/web/src/hooks/useMessages.ts @@ -12,6 +12,7 @@ export interface UseMessagesResult { error: string | null loadOlder: () => Promise upsert: (message: Message, tempId?: string) => void + patchMessage: (id: number, updater: (message: ClientMessage) => ClientMessage) => void addOptimistic: (message: ClientMessage) => void markSending: (tempId: string) => void failOptimistic: (tempId: string) => void @@ -111,6 +112,10 @@ export function useMessages(conversationId: number | null, currentUserId: number }) }, []) + const patchMessage = useCallback((id: number, updater: (message: ClientMessage) => ClientMessage) => { + setMessages((prev) => prev.map((item) => (item.id === id ? updater(item) : item))) + }, []) + const addOptimistic = useCallback((message: ClientMessage) => { setMessages((prev) => { if (message.temp_id && prev.some((item) => item.temp_id === message.temp_id)) return prev @@ -147,6 +152,7 @@ export function useMessages(conversationId: number | null, currentUserId: number error, loadOlder, upsert, + patchMessage, addOptimistic, markSending, failOptimistic, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7e03fae..8794c49 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Contact, Conversation, Message, SendMessageResult, User } from './types' +import type { Contact, Conversation, Media, Member, Message, SendMessageResult, User } from './types' const API_BASE = '/api/v1' @@ -98,6 +98,29 @@ async function request(path: string, options: RequestOptions = {}): Promise(res) } +async function requestForm(path: string, form: FormData, authRetry = true): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + credentials: 'include', + body: form, + }) + + if (res.status === 401 && authRetry) { + try { + await refreshSession() + } catch { + throw new UnauthorizedError() + } + return requestForm(path, form, false) + } + + if (!res.ok) { + throw new ApiError(await readError(res), res.status) + } + + return readJson(res) +} + export interface ApiClient { register(username: string, displayName: string, password: string): Promise login(username: string, password: string): Promise @@ -110,8 +133,20 @@ export interface ApiClient { removeContact(userId: number): Promise listConversations(): Promise createDM(userId: number): Promise + createGroup(name: string, memberIds: number[]): Promise + listMembers(conversationId: number): Promise + addMember(conversationId: number, userId: number): Promise + removeMember(conversationId: number, userId: number): Promise listMessages(conversationId: number, before?: number, limit?: number): Promise - sendMessage(conversationId: number, body: string, tempId: string): Promise + sendMessage( + conversationId: number, + body: string, + tempId: string, + mediaId?: number, + ): Promise + addReaction(conversationId: number, messageId: number, emoji: string): Promise + removeReaction(conversationId: number, messageId: number, emoji: string): Promise + uploadMedia(file: File): Promise } export const api: ApiClient = { @@ -167,17 +202,61 @@ export const api: ApiClient = { return request('/conversations', { method: 'POST', body: { user_id: userId } }) }, + createGroup(name, memberIds) { + return request('/conversations', { + method: 'POST', + body: { type: 'group', name, member_ids: memberIds }, + }) + }, + + listMembers(conversationId) { + return request(`/conversations/${conversationId}/members`) + }, + + addMember(conversationId, userId) { + return request(`/conversations/${conversationId}/members`, { + method: 'POST', + body: { user_id: userId }, + }) + }, + + removeMember(conversationId, userId) { + return request(`/conversations/${conversationId}/members/${userId}`, { method: 'DELETE' }) + }, + listMessages(conversationId, before, limit = 50) { const params = new URLSearchParams({ limit: String(limit) }) if (before !== undefined) params.set('before', String(before)) return request(`/conversations/${conversationId}/messages?${params.toString()}`) }, - async sendMessage(conversationId, body, tempId) { + async sendMessage(conversationId, body, tempId, mediaId) { + const payload: { body: string; temp_id: string; media_id?: number } = { body, temp_id: tempId } + if (mediaId !== undefined) payload.media_id = mediaId const message = await request(`/conversations/${conversationId}/messages`, { method: 'POST', - body: { body, temp_id: tempId }, + body: payload, }) return { temp_id: tempId, message } }, + + addReaction(conversationId, messageId, emoji) { + return request(`/conversations/${conversationId}/messages/${messageId}/reactions`, { + method: 'PUT', + body: { emoji }, + }) + }, + + removeReaction(conversationId, messageId, emoji) { + return request( + `/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, + { method: 'DELETE' }, + ) + }, + + uploadMedia(file) { + const form = new FormData() + form.append('file', file) + return requestForm('/media', form) + }, } diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index c616268..83a3ad3 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -76,6 +76,15 @@ export function minutesBetween(a: string, b: string): number { return Math.abs(first.getTime() - second.getTime()) / 60000 } +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 Б' + const units = ['Б', 'КБ', 'МБ', 'ГБ'] as const + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const value = bytes / 1024 ** index + const rounded = index === 0 || value >= 10 ? Math.round(value) : Number(value.toFixed(1)) + return `${rounded} ${units[index]}` +} + export function peerForConversation( conversation: Conversation, peers: Record, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 81b664e..5c3236a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -11,11 +11,27 @@ export interface Contact { added_at: string } +export interface Reaction { + emoji: string + user_id: number +} + +export interface Media { + id: number + filename: string + content_type: string + size: number + url: string + thumb_url?: string | null +} + export interface Message { id: number conversation_id: number sender_id: number body: string + reactions: Reaction[] + media?: Media | null created_at: string edited_at?: string | null deleted_at?: string | null @@ -32,11 +48,21 @@ export interface ClientMessage extends Message { export interface Conversation { id: number type: string + name: string + avatar: string | null created_at: string last_message?: Message | null unread_count?: number } +export type MemberRole = 'owner' | 'member' + +export interface Member { + user: User + role: MemberRole + joined_at: string +} + export interface SendMessageResult { temp_id: string message: Message @@ -51,6 +77,9 @@ export interface WsMessageCreated { sender_id: number body: string created_at: string + reactions?: Reaction[] + media_id?: number + media?: Media | null } } @@ -80,4 +109,64 @@ export interface WsRead { } } -export type WsEvent = WsMessageCreated | WsTyping | WsPresence | WsRead +export interface WsConversationAdded { + type: 'conversation.added' + data: { + conversation_id: number + } +} + +export interface WsConversationRemoved { + type: 'conversation.removed' + data: { + conversation_id: number + } +} + +export interface WsMemberAdded { + type: 'member.added' + data: { + conversation_id: number + user_id: number + } +} + +export interface WsMemberRemoved { + type: 'member.removed' + data: { + conversation_id: number + user_id: number + } +} + +export interface WsReactionAdded { + type: 'reaction.added' + data: { + conversation_id: number + message_id: number + user_id: number + emoji: string + } +} + +export interface WsReactionRemoved { + type: 'reaction.removed' + data: { + conversation_id: number + message_id: number + user_id: number + emoji: string + } +} + +export type WsEvent = + | WsMessageCreated + | WsTyping + | WsPresence + | WsRead + | WsConversationAdded + | WsConversationRemoved + | WsMemberAdded + | WsMemberRemoved + | WsReactionAdded + | WsReactionRemoved diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index 7ea896c..3cfd750 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -14,7 +14,18 @@ interface OutgoingRead { data: { conversation_id: number; message_id: number } } -const EVENT_TYPES: readonly WsEventType[] = ['message.created', 'typing', 'presence', 'read'] +const EVENT_TYPES: readonly WsEventType[] = [ + 'message.created', + 'typing', + 'presence', + 'read', + 'conversation.added', + 'conversation.removed', + 'member.added', + 'member.removed', + 'reaction.added', + 'reaction.removed', +] function isWsEvent(value: unknown): value is WsEvent { if (typeof value !== 'object' || value === null) return false diff --git a/web/src/screens/ChatApp.tsx b/web/src/screens/ChatApp.tsx index c6aceed..00e0f5e 100644 --- a/web/src/screens/ChatApp.tsx +++ b/web/src/screens/ChatApp.tsx @@ -7,7 +7,7 @@ import { useAuth } from '../auth/AuthContext' import { useConversations } from '../hooks/useConversations' import { useMessages } from '../hooks/useMessages' import { useRealtime } from '../hooks/useRealtime' -import type { ClientMessage, Message, User, WsEvent } from '../lib/types' +import type { ClientMessage, Media, Member, Message, User, WsEvent } from '../lib/types' interface ChatAppProps { user: User @@ -22,8 +22,9 @@ export default function ChatApp({ user }: ChatAppProps) { const [typing, setTyping] = useState>({}) const [dialogOpen, setDialogOpen] = useState(false) const [focusTick, setFocusTick] = useState(0) + const [membersVersion, setMembersVersion] = useState(0) - const { conversations, loading, error, reload, add, patch } = useConversations() + const { conversations, loading, error, reload, add, remove, patch } = useConversations() const { messages, loading: messagesLoading, @@ -32,6 +33,7 @@ export default function ChatApp({ user }: ChatAppProps) { error: messagesError, loadOlder, upsert, + patchMessage, addOptimistic, markSending, failOptimistic, @@ -96,8 +98,16 @@ export default function ChatApp({ user }: ChatAppProps) { (event: WsEvent) => { switch (event.type) { case 'message.created': { - const { temp_id, conversation_id, message_id, sender_id, body, created_at } = event.data - const message: Message = { id: message_id, conversation_id, sender_id, body, created_at } + const { temp_id, conversation_id, message_id, sender_id, body, created_at, reactions, media } = event.data + const message: Message = { + id: message_id, + conversation_id, + sender_id, + body, + created_at, + reactions: reactions ?? [], + media: media ?? null, + } if (conversation_id === selectedIdRef.current || sender_id === user.id) { upsert(message, temp_id) } @@ -146,23 +156,64 @@ export default function ChatApp({ user }: ChatAppProps) { if (conversation_id === selectedIdRef.current) markReadUpTo(message_id) break } + case 'reaction.added': { + const { conversation_id, message_id, user_id, emoji } = event.data + if (conversation_id !== selectedIdRef.current) break + patchMessage(message_id, (message) => { + if (message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user_id)) { + return message + } + return { ...message, reactions: [...message.reactions, { emoji, user_id }] } + }) + break + } + case 'reaction.removed': { + const { conversation_id, message_id, user_id, emoji } = event.data + if (conversation_id !== selectedIdRef.current) break + patchMessage(message_id, (message) => ({ + ...message, + reactions: message.reactions.filter( + (reaction) => !(reaction.emoji === emoji && reaction.user_id === user_id), + ), + })) + break + } + case 'member.added': + case 'member.removed': { + if (event.data.conversation_id === selectedIdRef.current) { + setMembersVersion((version) => version + 1) + } + break + } + case 'conversation.added': { + void reload() + break + } + case 'conversation.removed': { + const { conversation_id } = event.data + remove(conversation_id) + if (selectedIdRef.current === conversation_id) setSelectedId(null) + break + } } }, - [markReadUpTo, patch, reload, upsert, user.id], + [markReadUpTo, patch, patchMessage, reload, remove, upsert, user.id], ) const realtime = useRealtime(handleEvent) const sendMessage = useCallback( - async (conversationId: number, body: string) => { + async (conversationId: number, body: string, media: Media | null) => { const text = body.trim() - if (!text) return + if (!text && !media) return const tempId = crypto.randomUUID() const optimistic: ClientMessage = { id: -Date.now(), conversation_id: conversationId, sender_id: user.id, body: text, + reactions: [], + media, created_at: new Date().toISOString(), temp_id: tempId, status: 'sending', @@ -170,7 +221,7 @@ export default function ChatApp({ user }: ChatAppProps) { addOptimistic(optimistic) patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 })) try { - const result = await api.sendMessage(conversationId, text, tempId) + const result = await api.sendMessage(conversationId, text, tempId, media?.id) upsert(result.message, tempId) patch(conversationId, (conversation) => ({ ...conversation, @@ -190,7 +241,7 @@ export default function ChatApp({ user }: ChatAppProps) { if (!tempId) return markSending(tempId) try { - const result = await api.sendMessage(message.conversation_id, message.body, tempId) + const result = await api.sendMessage(message.conversation_id, message.body, tempId, message.media?.id) upsert(result.message, tempId) patch(message.conversation_id, (conversation) => ({ ...conversation, @@ -204,6 +255,22 @@ export default function ChatApp({ user }: ChatAppProps) { [failOptimistic, markSending, patch, upsert], ) + const handleReaction = useCallback( + async (message: ClientMessage, emoji: string) => { + if (message.id <= 0) return + const mine = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user.id) + try { + const updated = mine + ? await api.removeReaction(message.conversation_id, message.id, emoji) + : await api.addReaction(message.conversation_id, message.id, emoji) + upsert(updated) + } catch { + return + } + }, + [upsert, user.id], + ) + const sendTyping = useCallback( (conversationId: number, isTyping: boolean) => { realtime.sendTyping(conversationId, isTyping) @@ -233,6 +300,23 @@ export default function ChatApp({ user }: ChatAppProps) { [add, loadContacts, rememberUser], ) + const handleCreateGroup = useCallback( + async (name: string, memberIds: number[]) => { + const conversation = await api.createGroup(name, memberIds) + add(conversation) + setSelectedId(conversation.id) + setMembersVersion((version) => version + 1) + }, + [add], + ) + + const handleMembersLoaded = useCallback( + (members: Member[]) => { + members.forEach((member) => rememberUser(member.user)) + }, + [rememberUser], + ) + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null @@ -264,6 +348,7 @@ export default function ChatApp({ user }: ChatAppProps) { /> conversation.id === selectedId) ?? null} currentUser={user} @@ -276,11 +361,15 @@ export default function ChatApp({ user }: ChatAppProps) { loadingOlder={loadingOlder} hasMore={hasMore} error={messagesError} + membersVersion={membersVersion} onBack={() => setSelectedId(null)} onSend={sendMessage} onTyping={sendTyping} onLoadOlder={() => void loadOlder()} onRetry={resendMessage} + onReact={(message, emoji) => void handleReaction(message, emoji)} + onMembersChanged={() => setMembersVersion((version) => version + 1)} + onMembersLoaded={handleMembersLoaded} /> @@ -288,6 +377,7 @@ export default function ChatApp({ user }: ChatAppProps) { setDialogOpen(false)} onStartChat={handleStartChat} + onCreateGroup={handleCreateGroup} onContactsChanged={() => void loadContacts()} /> )}