big update: added reactions, groups, media share
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"messenger/internal/auth"
|
"messenger/internal/auth"
|
||||||
|
"messenger/internal/media"
|
||||||
"messenger/internal/platform/config"
|
"messenger/internal/platform/config"
|
||||||
"messenger/internal/platform/logger"
|
"messenger/internal/platform/logger"
|
||||||
"messenger/internal/realtime"
|
"messenger/internal/realtime"
|
||||||
@@ -52,7 +53,13 @@ func main() {
|
|||||||
tm := auth.NewTokenManager(cfg.JWTSecret)
|
tm := auth.NewTokenManager(cfg.JWTSecret)
|
||||||
hub := realtime.NewHub(st)
|
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
|
// Run the server in a goroutine so that shutdown can be triggered
|
||||||
// from the signal handler below.
|
// from the signal handler below.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
|
golang.org/x/image v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -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=
|
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 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
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 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"messenger/internal/auth"
|
"messenger/internal/auth"
|
||||||
|
"messenger/internal/media"
|
||||||
"messenger/internal/platform/config"
|
"messenger/internal/platform/config"
|
||||||
"messenger/internal/realtime"
|
"messenger/internal/realtime"
|
||||||
"messenger/internal/store"
|
"messenger/internal/store"
|
||||||
@@ -17,17 +18,19 @@ type apiHandler struct {
|
|||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
secure bool
|
secure bool
|
||||||
|
media media.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler builds the /api/v1 route tree with all middleware applied.
|
// NewHandler builds the /api/v1 route tree with all middleware applied.
|
||||||
// static, when non-nil, serves the built frontend for non-API paths.
|
// 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{
|
h := &apiHandler{
|
||||||
store: st,
|
store: st,
|
||||||
tokens: tm,
|
tokens: tm,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
log: log,
|
log: log,
|
||||||
secure: cfg.Environment == "production",
|
secure: cfg.Environment == "production",
|
||||||
|
media: ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
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("DELETE /api/v1/contacts/{user_id}", requireAuth(tm, h.removeContact))
|
||||||
|
|
||||||
mux.Handle("GET /api/v1/conversations", requireAuth(tm, h.listConversations))
|
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("GET /api/v1/conversations/{id}/messages", requireAuth(tm, h.listMessages))
|
||||||
mux.Handle("POST /api/v1/conversations/{id}/messages", requireAuth(tm, h.sendMessage))
|
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)
|
mux.Handle("GET /ws", authHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"messenger/internal/realtime"
|
"messenger/internal/realtime"
|
||||||
@@ -13,33 +14,61 @@ import (
|
|||||||
type conversationJSON struct {
|
type conversationJSON struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastMessage *messageJSON `json:"last_message,omitempty"`
|
LastMessage *messageJSON `json:"last_message,omitempty"`
|
||||||
UnreadCount int64 `json:"unread_count"`
|
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 {
|
type messageJSON struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ConversationID int64 `json:"conversation_id"`
|
ConversationID int64 `json:"conversation_id"`
|
||||||
SenderID int64 `json:"sender_id"`
|
SenderID int64 `json:"sender_id"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
Reactions []reactionJSON `json:"reactions"`
|
||||||
|
Media *mediaJSON `json:"media,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||||
DeletedAt *time.Time `json:"deleted_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 {
|
func toMessageJSON(m *store.Message) messageJSON {
|
||||||
return messageJSON{
|
return messageJSON{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
ConversationID: m.ConversationID,
|
ConversationID: m.ConversationID,
|
||||||
SenderID: m.SenderID,
|
SenderID: m.SenderID,
|
||||||
Body: m.Body,
|
Body: m.Body,
|
||||||
|
Reactions: []reactionJSON{},
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
EditedAt: m.EditedAt,
|
EditedAt: m.EditedAt,
|
||||||
DeletedAt: m.DeletedAt,
|
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) {
|
func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, _ := userIDFrom(r.Context())
|
userID, _ := userIDFrom(r.Context())
|
||||||
summaries, err := h.store.ListConversationsForUser(r.Context(), userID)
|
summaries, err := h.store.ListConversationsForUser(r.Context(), userID)
|
||||||
@@ -52,6 +81,8 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
|||||||
item := conversationJSON{
|
item := conversationJSON{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Type: s.Type,
|
Type: s.Type,
|
||||||
|
Name: s.Name,
|
||||||
|
Avatar: s.Avatar,
|
||||||
CreatedAt: s.CreatedAt,
|
CreatedAt: s.CreatedAt,
|
||||||
UnreadCount: s.UnreadCount,
|
UnreadCount: s.UnreadCount,
|
||||||
}
|
}
|
||||||
@@ -64,12 +95,27 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
|
type createConversationRequest struct {
|
||||||
userID, _ := userIDFrom(r.Context())
|
Type string `json:"type"`
|
||||||
var req struct {
|
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MemberIDs []int64 `json:"member_ids"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
|
||||||
|
func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := userIDFrom(r.Context())
|
||||||
|
var req createConversationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type == "group" || req.Name != "" {
|
||||||
|
h.createGroup(w, r, userID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID <= 0 {
|
||||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -82,7 +128,148 @@ func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
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) {
|
func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -115,8 +302,38 @@ func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]messageJSON, 0, len(messages))
|
out := make([]messageJSON, 0, len(messages))
|
||||||
|
msgIDs := make([]int64, 0, len(messages))
|
||||||
|
mediaIDs := make(map[int64]struct{})
|
||||||
for i := range messages {
|
for i := range messages {
|
||||||
out = append(out, toMessageJSON(&messages[i]))
|
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)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
@@ -124,6 +341,7 @@ func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
type sendMessageRequest struct {
|
type sendMessageRequest struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
TempID string `json:"temp_id"`
|
TempID string `json:"temp_id"`
|
||||||
|
MediaID *int64 `json:"media_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
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")
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Body == "" {
|
if req.Body == "" && req.MediaID == nil {
|
||||||
writeError(w, http.StatusBadRequest, "body is required")
|
writeError(w, http.StatusBadRequest, "body or media_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Body) > 4000 {
|
if len(req.Body) > 4000 {
|
||||||
@@ -153,20 +371,50 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.hub.PublishMessageCreated(convID, realtime.MessageCreated{
|
wsEvent := realtime.MessageCreated{
|
||||||
TempID: req.TempID,
|
TempID: req.TempID,
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
MessageID: msg.ID,
|
MessageID: msg.ID,
|
||||||
SenderID: userID,
|
SenderID: userID,
|
||||||
Body: msg.Body,
|
Body: msg.Body,
|
||||||
CreatedAt: msg.CreatedAt,
|
CreatedAt: msg.CreatedAt,
|
||||||
})
|
}
|
||||||
|
if attached != nil {
|
||||||
writeJSON(w, http.StatusCreated, toMessageJSON(msg))
|
wsEvent.MediaID = &attached.ID
|
||||||
|
info := toRealtimeMedia(attached)
|
||||||
|
wsEvent.Media = &info
|
||||||
|
}
|
||||||
|
h.hub.PublishMessageCreated(convID, wsEvent)
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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] == '\\')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ type Config struct {
|
|||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
// JWTSecret signs access tokens. Must be set in production.
|
// JWTSecret signs access tokens. Must be set in production.
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
// MediaDir is the local disk location for uploaded attachments.
|
||||||
|
MediaDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from the environment, falling back to
|
// Load reads configuration from the environment, falling back to
|
||||||
@@ -35,6 +37,7 @@ func Load() (*Config, error) {
|
|||||||
Environment: fromEnv("MESSENGER_ENV", "development"),
|
Environment: fromEnv("MESSENGER_ENV", "development"),
|
||||||
DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"),
|
DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"),
|
||||||
JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"),
|
JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"),
|
||||||
|
MediaDir: fromEnv("MESSENGER_MEDIA_DIR", "./media"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") {
|
for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const (
|
|||||||
EventTyping = "typing"
|
EventTyping = "typing"
|
||||||
EventPresence = "presence"
|
EventPresence = "presence"
|
||||||
EventRead = "read"
|
EventRead = "read"
|
||||||
|
EventConversationAdded = "conversation.added"
|
||||||
|
EventConversationRemoved = "conversation.removed"
|
||||||
|
EventMemberAdded = "member.added"
|
||||||
|
EventMemberRemoved = "member.removed"
|
||||||
|
EventReactionAdded = "reaction.added"
|
||||||
|
EventReactionRemoved = "reaction.removed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Presence status values.
|
// Presence status values.
|
||||||
@@ -28,9 +34,21 @@ type MessageCreated struct {
|
|||||||
MessageID int64 `json:"message_id"`
|
MessageID int64 `json:"message_id"`
|
||||||
SenderID int64 `json:"sender_id"`
|
SenderID int64 `json:"sender_id"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
MediaID *int64 `json:"media_id,omitempty"`
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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.
|
// Typing is published when a user starts or stops typing in a conversation.
|
||||||
type Typing struct {
|
type Typing struct {
|
||||||
ConversationID int64 `json:"conversation_id"`
|
ConversationID int64 `json:"conversation_id"`
|
||||||
@@ -51,6 +69,25 @@ type Read struct {
|
|||||||
MessageID int64 `json:"message_id"`
|
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.
|
// envelope is the wire format for every message, both inbound and outbound.
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@@ -84,6 +84,67 @@ func (h *Hub) PublishRead(conversationID, userID, messageID int64) {
|
|||||||
h.sendToConversation(conversationID, envelope{Type: EventRead, Data: mustMarshal(evt)}, userID)
|
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) {
|
func (h *Hub) register(c *client, conversationIDs []int64) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import (
|
|||||||
|
|
||||||
"messenger/internal/api"
|
"messenger/internal/api"
|
||||||
"messenger/internal/auth"
|
"messenger/internal/auth"
|
||||||
|
"messenger/internal/media"
|
||||||
"messenger/internal/platform/config"
|
"messenger/internal/platform/config"
|
||||||
"messenger/internal/realtime"
|
"messenger/internal/realtime"
|
||||||
"messenger/internal/store"
|
"messenger/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New builds an *http.Server with all routes and middleware applied.
|
// 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 := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
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}
|
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)))
|
handler := logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux)))
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
|||||||
// Check existing DM
|
// Check existing DM
|
||||||
var conv Conversation
|
var conv Conversation
|
||||||
err := s.pool.QueryRow(ctx, `
|
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
|
FROM conversations c
|
||||||
JOIN conversation_members m1 ON m1.conversation_id=c.id AND m1.user_id=$1
|
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
|
JOIN conversation_members m2 ON m2.conversation_id=c.id AND m2.user_id=$2
|
||||||
WHERE c.type='dm'
|
WHERE c.type='dm'
|
||||||
LIMIT 1
|
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 {
|
if err == nil {
|
||||||
return &conv, nil
|
return &conv, nil
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
|||||||
|
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO conversations(type) VALUES('dm')
|
INSERT INTO conversations(type) VALUES('dm')
|
||||||
RETURNING id, type, created_at
|
RETURNING id, type, name, avatar, created_at
|
||||||
`).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
`).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *Store) GetConversationByID(ctx context.Context, id int64) (*Conversation, error) {
|
||||||
var conv Conversation
|
var conv Conversation
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT id, type, created_at FROM conversations WHERE id=$1
|
SELECT id, type, name, avatar, created_at FROM conversations WHERE id=$1
|
||||||
`, id).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
`, id).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
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.
|
// ListConversationsForUser returns summaries of conversations for a user.
|
||||||
func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]ConversationSummary, error) {
|
func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]ConversationSummary, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
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,
|
m.id, m.conversation_id, m.sender_id, m.body, m.created_at, m.edited_at, m.deleted_at,
|
||||||
uc.unread_count
|
uc.unread_count
|
||||||
FROM conversation_members cm
|
FROM conversation_members cm
|
||||||
@@ -156,7 +156,7 @@ func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]C
|
|||||||
msgDeleted *time.Time
|
msgDeleted *time.Time
|
||||||
)
|
)
|
||||||
if err := rows.Scan(
|
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,
|
&msgID, &msgConvID, &msgSender, &msgBody, &msgCreated, &msgEdited, &msgDeleted,
|
||||||
&cs.UnreadCount,
|
&cs.UnreadCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
t.Fatalf("ListConversationsForUser: %v, %v", sums, err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("InsertMessage: %v", err)
|
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 {
|
if err := db.UpdateLastRead(ctx, conv.ID, u2.ID); err != nil {
|
||||||
t.Fatalf("UpdateLastRead: %v", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,14 +8,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// InsertMessage creates a new message in a conversation and returns it.
|
// 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
|
var m Message
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO messages(conversation_id, sender_id, body)
|
INSERT INTO messages(conversation_id, sender_id, body, media_id)
|
||||||
VALUES($1, $2, $3)
|
VALUES($1, $2, $3, $4)
|
||||||
RETURNING id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
RETURNING id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||||
`, conversationID, senderID, body).Scan(
|
`, conversationID, senderID, body, mediaID).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,
|
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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) {
|
func (s *Store) GetMessageByID(ctx context.Context, id int64) (*Message, error) {
|
||||||
var m Message
|
var m Message
|
||||||
err := s.pool.QueryRow(ctx, `
|
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
|
FROM messages WHERE id=$1
|
||||||
`, id).Scan(
|
`, 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,
|
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,14 +51,14 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i
|
|||||||
var err error
|
var err error
|
||||||
if before > 0 {
|
if before > 0 {
|
||||||
rows, err = s.pool.Query(ctx, `
|
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
|
FROM messages
|
||||||
WHERE conversation_id=$1 AND id<$2
|
WHERE conversation_id=$1 AND id<$2
|
||||||
ORDER BY id DESC LIMIT $3
|
ORDER BY id DESC LIMIT $3
|
||||||
`, conversationID, before, limit)
|
`, conversationID, before, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.pool.Query(ctx, `
|
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
|
FROM messages
|
||||||
WHERE conversation_id=$1
|
WHERE conversation_id=$1
|
||||||
ORDER BY id DESC LIMIT $2
|
ORDER BY id DESC LIMIT $2
|
||||||
@@ -72,7 +73,7 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m Message
|
var m Message
|
||||||
if err := rows.Scan(
|
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,
|
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,9 +42,18 @@ type Session struct {
|
|||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
ID int64
|
ID int64
|
||||||
Type string
|
Type string
|
||||||
|
Name string
|
||||||
|
Avatar string
|
||||||
CreatedAt time.Time
|
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.
|
// ConversationSummary provides a conversation with its last message and unread count.
|
||||||
type ConversationSummary struct {
|
type ConversationSummary struct {
|
||||||
Conversation
|
Conversation
|
||||||
@@ -58,6 +67,7 @@ type Message struct {
|
|||||||
ConversationID int64
|
ConversationID int64
|
||||||
SenderID int64
|
SenderID int64
|
||||||
Body string
|
Body string
|
||||||
|
MediaID *int64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
EditedAt *time.Time
|
EditedAt *time.Time
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { avatarColor, initials } from '../lib/format'
|
import { avatarColor, initials } from '../lib/format'
|
||||||
|
import { IconUsers } from './icons'
|
||||||
|
|
||||||
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
|
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
|
||||||
@@ -38,3 +39,26 @@ export default function Avatar({ name, seed, size = 'md', online = false, classN
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={`relative inline-flex shrink-0 ${className}`}>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full text-white/95 shadow-inner ring-1 ring-white/10 ${SIZE_CLASSES[size]}`}
|
||||||
|
style={{ backgroundImage: `linear-gradient(145deg, ${background}, color-mix(in srgb, ${background} 62%, #000))` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<IconUsers className="h-[55%] w-[55%]" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import Avatar from './Avatar'
|
import Avatar, { GroupAvatar } from './Avatar'
|
||||||
import MessageList from './MessageList'
|
import MessageList from './MessageList'
|
||||||
import { IconArrowLeft, IconChat, IconSend } from './icons'
|
import MembersDialog from './MembersDialog'
|
||||||
import { peerForConversation, userTitle } from '../lib/format'
|
import { IconArrowLeft, IconChat, IconClose, IconFile, IconPaperclip, IconSend, IconUsers } from './icons'
|
||||||
import type { ClientMessage, Conversation, User } from '../lib/types'
|
import { api } from '../lib/api'
|
||||||
|
import { formatBytes, peerForConversation, userTitle } from '../lib/format'
|
||||||
|
import type { ClientMessage, Conversation, Media, Member, User } from '../lib/types'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -18,11 +20,15 @@ interface ChatWindowProps {
|
|||||||
loadingOlder: boolean
|
loadingOlder: boolean
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
membersVersion: number
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSend: (conversationId: number, body: string) => void
|
onSend: (conversationId: number, body: string, media: Media | null) => void
|
||||||
onTyping: (conversationId: number, isTyping: boolean) => void
|
onTyping: (conversationId: number, isTyping: boolean) => void
|
||||||
onLoadOlder: () => void
|
onLoadOlder: () => void
|
||||||
onRetry: (message: ClientMessage) => void
|
onRetry: (message: ClientMessage) => void
|
||||||
|
onReact: (message: ClientMessage, emoji: string) => void
|
||||||
|
onMembersChanged: () => void
|
||||||
|
onMembersLoaded: (members: Member[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function TypingDots() {
|
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 {
|
interface ComposerProps {
|
||||||
onSend: (body: string) => void
|
onSend: (body: string, media: Media | null) => void
|
||||||
onTyping: (isTyping: boolean) => void
|
onTyping: (isTyping: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function Composer({ onSend, onTyping }: ComposerProps) {
|
function Composer({ onSend, onTyping }: ComposerProps) {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
|
const [attachment, setAttachment] = useState<Media | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const typingRef = useRef(false)
|
const typingRef = useRef(false)
|
||||||
const typingTimerRef = useRef<number | null>(null)
|
const typingTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
@@ -72,15 +90,17 @@ function Composer({ onSend, onTyping }: ComposerProps) {
|
|||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return
|
if ((!trimmed && !attachment) || uploading) return
|
||||||
onSend(trimmed)
|
onSend(trimmed, attachment)
|
||||||
setValue('')
|
setValue('')
|
||||||
|
setAttachment(null)
|
||||||
|
setUploadError(null)
|
||||||
stopTyping()
|
stopTyping()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
resize()
|
resize()
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
})
|
})
|
||||||
}, [onSend, resize, stopTyping, value])
|
}, [attachment, onSend, resize, stopTyping, uploading, value])
|
||||||
|
|
||||||
const handleChange = (next: string) => {
|
const handleChange = (next: string) => {
|
||||||
setValue(next)
|
setValue(next)
|
||||||
@@ -96,9 +116,81 @@ function Composer({ onSend, onTyping }: ComposerProps) {
|
|||||||
}, 2000)
|
}, 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 (
|
return (
|
||||||
<div className="border-t border-line/80 bg-panel/70 px-3 py-3 backdrop-blur sm:px-5">
|
<div className="border-t border-line/80 bg-panel/70 px-3 py-3 backdrop-blur sm:px-5">
|
||||||
|
{attachment && (
|
||||||
|
<div className="mb-2 flex items-center gap-3 rounded-2xl border border-line bg-surface/80 p-2">
|
||||||
|
{isImage ? (
|
||||||
|
<img
|
||||||
|
src={attachment.thumb_url ?? attachment.url}
|
||||||
|
alt={attachment.filename}
|
||||||
|
className="h-14 w-14 shrink-0 rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-surface-3 text-accent">
|
||||||
|
<IconFile className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-fg">{attachment.filename}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{formatBytes(attachment.size)}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAttachment(null)}
|
||||||
|
aria-label="Убрать вложение"
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition hover:bg-surface-2 hover:text-danger"
|
||||||
|
>
|
||||||
|
<IconClose className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadError && <p className="mb-2 px-1 text-xs text-danger">{uploadError}</p>}
|
||||||
|
|
||||||
<div className="flex items-end gap-2 rounded-2xl border border-line bg-surface/80 p-1.5 transition focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
|
<div className="flex items-end gap-2 rounded-2xl border border-line bg-surface/80 p-1.5 transition focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ''
|
||||||
|
if (file) void handleFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
aria-label="Прикрепить файл"
|
||||||
|
title="Прикрепить файл"
|
||||||
|
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-surface-3 border-t-accent" />
|
||||||
|
) : (
|
||||||
|
<IconPaperclip className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<label htmlFor="composer" className="sr-only">
|
<label htmlFor="composer" className="sr-only">
|
||||||
Сообщение
|
Сообщение
|
||||||
</label>
|
</label>
|
||||||
@@ -120,7 +212,7 @@ function Composer({ onSend, onTyping }: ComposerProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={!value.trim()}
|
disabled={!canSend}
|
||||||
aria-label="Отправить сообщение"
|
aria-label="Отправить сообщение"
|
||||||
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20 transition enabled:hover:brightness-110 enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
|
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20 transition enabled:hover:brightness-110 enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
@@ -147,12 +239,44 @@ export default function ChatWindow({
|
|||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasMore,
|
hasMore,
|
||||||
error,
|
error,
|
||||||
|
membersVersion,
|
||||||
onBack,
|
onBack,
|
||||||
onSend,
|
onSend,
|
||||||
onTyping,
|
onTyping,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onReact,
|
||||||
|
onMembersChanged,
|
||||||
|
onMembersLoaded,
|
||||||
}: ChatWindowProps) {
|
}: ChatWindowProps) {
|
||||||
|
const [members, setMembers] = useState<Member[]>([])
|
||||||
|
const [membersLoading, setMembersLoading] = useState(conversation?.type === 'group')
|
||||||
|
const [membersOpen, setMembersOpen] = useState(false)
|
||||||
|
|
||||||
|
const conversationId = conversation?.id ?? null
|
||||||
|
const isGroup = conversation?.type === 'group'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversationId === null || !isGroup) return
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.listMembers(conversationId)
|
||||||
|
.then((list) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setMembers(list)
|
||||||
|
onMembersLoaded(list)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setMembers([])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setMembersLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [conversationId, isGroup, membersVersion, onMembersLoaded])
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return (
|
return (
|
||||||
<section className={`${className} flex-1 flex-col items-center justify-center gap-4 bg-panel/40 px-6 text-center`}>
|
<section className={`${className} flex-1 flex-col items-center justify-center gap-4 bg-panel/40 px-6 text-center`}>
|
||||||
@@ -169,12 +293,17 @@ export default function ChatWindow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
|
const peer = isGroup ? null : peerForConversation(conversation, peers, directory, currentUser.id)
|
||||||
const peerName = peer ? userTitle(peer) : `Чат #${conversation.id}`
|
const title = isGroup
|
||||||
|
? conversation.name || `Группа #${conversation.id}`
|
||||||
|
: peer
|
||||||
|
? userTitle(peer)
|
||||||
|
: `Чат #${conversation.id}`
|
||||||
const isOnline = peer ? presence[peer.id] === true : false
|
const isOnline = peer ? presence[peer.id] === true : false
|
||||||
|
|
||||||
let subtitle = 'нет данных о статусе'
|
let subtitle = 'нет данных о статусе'
|
||||||
if (peerIsTyping) subtitle = 'печатает…'
|
if (isGroup) subtitle = membersLoading ? 'загрузка участников…' : membersLabel(members.length)
|
||||||
|
else if (peerIsTyping) subtitle = 'печатает…'
|
||||||
else if (isOnline) subtitle = 'в сети'
|
else if (isOnline) subtitle = 'в сети'
|
||||||
else if (peer) subtitle = 'не в сети'
|
else if (peer) subtitle = 'не в сети'
|
||||||
|
|
||||||
@@ -190,8 +319,10 @@ export default function ChatWindow({
|
|||||||
<IconArrowLeft className="h-5 w-5" />
|
<IconArrowLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{peer ? (
|
{isGroup ? (
|
||||||
<Avatar name={peerName} seed={peer.id} size="md" online={isOnline} />
|
<GroupAvatar name={title} seed={conversation.id} size="md" />
|
||||||
|
) : peer ? (
|
||||||
|
<Avatar name={title} seed={peer.id} size="md" online={isOnline} />
|
||||||
) : (
|
) : (
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-2 font-semibold text-muted">
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-2 font-semibold text-muted">
|
||||||
#
|
#
|
||||||
@@ -199,12 +330,23 @@ export default function ChatWindow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="truncate font-display text-[0.9375rem] font-semibold text-fg">{peerName}</h2>
|
<h2 className="truncate font-display text-[0.9375rem] font-semibold text-fg">{title}</h2>
|
||||||
<p className="flex items-center gap-1.5 truncate text-xs text-muted">
|
<p className="flex items-center gap-1.5 truncate text-xs text-muted">
|
||||||
{peerIsTyping && <TypingDots />}
|
{peerIsTyping && !isGroup && <TypingDots />}
|
||||||
<span className={peerIsTyping ? 'text-accent' : ''}>{subtitle}</span>
|
<span className={peerIsTyping && !isGroup ? 'text-accent' : ''}>{subtitle}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isGroup && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMembersOpen(true)}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-line bg-surface px-3 py-2 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent"
|
||||||
|
>
|
||||||
|
<IconUsers className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Участники</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MessageList
|
<MessageList
|
||||||
@@ -217,13 +359,25 @@ export default function ChatWindow({
|
|||||||
error={error}
|
error={error}
|
||||||
onLoadOlder={onLoadOlder}
|
onLoadOlder={onLoadOlder}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
onReact={onReact}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Composer
|
<Composer
|
||||||
key={conversation.id}
|
key={conversation.id}
|
||||||
onSend={(body) => onSend(conversation.id, body)}
|
onSend={(body, media) => onSend(conversation.id, body, media)}
|
||||||
onTyping={(isTyping) => onTyping(conversation.id, isTyping)}
|
onTyping={(isTyping) => onTyping(conversation.id, isTyping)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isGroup && membersOpen && (
|
||||||
|
<MembersDialog
|
||||||
|
conversationId={conversation.id}
|
||||||
|
currentUser={currentUser}
|
||||||
|
members={members}
|
||||||
|
loading={membersLoading}
|
||||||
|
onClose={() => setMembersOpen(false)}
|
||||||
|
onChanged={onMembersChanged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<User[]>([])
|
||||||
|
const [resultQuery, setResultQuery] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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<void>) => {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 backdrop-blur-sm sm:items-center sm:p-6">
|
||||||
|
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="members-title"
|
||||||
|
className="relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 id="members-title" className="font-display text-base font-semibold text-fg">
|
||||||
|
Участники
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{loading ? 'Загрузка…' : `${members.length} в группе`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<IconClose className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 pt-4">
|
||||||
|
<div className="relative">
|
||||||
|
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||||
|
<label htmlFor="member-search" className="sr-only">
|
||||||
|
Поиск пользователей
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="member-search"
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
|
||||||
|
{trimmed.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="px-2.5 pb-1 text-[11px] font-semibold tracking-wide text-faint uppercase">Найдены</p>
|
||||||
|
{searching ? (
|
||||||
|
<p className="px-2.5 py-2 text-sm text-muted">Поиск…</p>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<p className="px-2.5 py-2 text-sm text-muted">Некого добавить</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{candidates.map((user) => {
|
||||||
|
const busy = busyId === user.id
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70"
|
||||||
|
>
|
||||||
|
<Avatar name={userTitle(user)} seed={user.id} size="md" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-fg">{userTitle(user)}</p>
|
||||||
|
<p className="truncate text-xs text-muted">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleAdd(user)}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-line px-2.5 py-1.5 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<IconUserPlus className="h-4 w-4" />
|
||||||
|
{busy ? '…' : 'Добавить'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="px-2.5 pb-1 text-[11px] font-semibold tracking-wide text-faint uppercase">В группе</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-1.5 p-1">
|
||||||
|
{[0, 1, 2].map((row) => (
|
||||||
|
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
|
||||||
|
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
|
||||||
|
<span className="h-3 w-1/2 animate-pulse-soft rounded-full bg-surface-2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{members.map((member) => {
|
||||||
|
const isSelf = member.user.id === currentUser.id
|
||||||
|
const canRemove = member.role !== 'owner' && (isOwner || isSelf)
|
||||||
|
const busy = busyId === member.user.id
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={member.user.id}
|
||||||
|
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70"
|
||||||
|
>
|
||||||
|
<Avatar name={userTitle(member.user)} seed={member.user.id} size="md" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center gap-2 truncate text-sm font-semibold text-fg">
|
||||||
|
<span className="truncate">{userTitle(member.user)}</span>
|
||||||
|
{member.role === 'owner' && (
|
||||||
|
<span className="shrink-0 rounded-full border border-accent/40 bg-accent-ghost px-2 py-0.5 text-[10px] font-semibold text-accent">
|
||||||
|
владелец
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelf && member.role !== 'owner' && (
|
||||||
|
<span className="shrink-0 text-[10px] font-medium text-faint">вы</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted">@{member.user.username}</p>
|
||||||
|
</div>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRemove(member)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={isSelf ? 'Покинуть группу' : 'Удалить участника'}
|
||||||
|
title={isSelf ? 'Покинуть группу' : 'Удалить участника'}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition hover:bg-surface-2 hover:text-danger disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<IconTrash className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Avatar from './Avatar'
|
import Avatar from './Avatar'
|
||||||
import { IconAlert, IconCheck, IconCheckCheck } from './icons'
|
import { IconAlert, IconCheck, IconCheckCheck, IconFile, IconSmile } from './icons'
|
||||||
import { formatTime, userTitle } from '../lib/format'
|
import { formatBytes, formatTime, userTitle } from '../lib/format'
|
||||||
import type { ClientMessage, User } from '../lib/types'
|
import type { ClientMessage, Media, User } from '../lib/types'
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ClientMessage
|
message: ClientMessage
|
||||||
@@ -9,17 +10,93 @@ interface MessageBubbleProps {
|
|||||||
sender: User | null
|
sender: User | null
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
showName: boolean
|
showName: boolean
|
||||||
|
currentUserId: number
|
||||||
onRetry: (message: ClientMessage) => void
|
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 (
|
||||||
|
<a
|
||||||
|
href={media.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block overflow-hidden rounded-xl"
|
||||||
|
title={media.filename}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={media.filename}
|
||||||
|
loading="lazy"
|
||||||
|
className="max-h-72 w-full max-w-xs object-cover transition duration-300 hover:brightness-110"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaFile({ media }: { media: Media }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={media.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-line/70 bg-surface/60 px-3 py-2.5 transition hover:border-accent/50"
|
||||||
|
>
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-3 text-accent">
|
||||||
|
<IconFile className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-fg">{media.filename}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{formatBytes(media.size)}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBubble({
|
||||||
|
message,
|
||||||
|
isOwn,
|
||||||
|
sender,
|
||||||
|
showAvatar,
|
||||||
|
showName,
|
||||||
|
currentUserId,
|
||||||
|
onRetry,
|
||||||
|
onReact,
|
||||||
|
}: MessageBubbleProps) {
|
||||||
const name = sender ? userTitle(sender) : `Пользователь #${message.sender_id}`
|
const name = sender ? userTitle(sender) : `Пользователь #${message.sender_id}`
|
||||||
const isSending = message.status === 'sending'
|
const isSending = message.status === 'sending'
|
||||||
const isError = message.status === 'error'
|
const isError = message.status === 'error'
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, number[]>()
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex w-full animate-msg-in items-end gap-2.5 px-3 sm:px-5 ${isOwn ? 'justify-end' : 'justify-start'}`}
|
className={`group flex w-full animate-msg-in items-end gap-2.5 px-3 sm:px-5 ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
{!isOwn &&
|
{!isOwn &&
|
||||||
(showAvatar ? (
|
(showAvatar ? (
|
||||||
@@ -29,9 +106,30 @@ export default function MessageBubble({ message, isOwn, sender, showAvatar, show
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className={`flex max-w-[82%] flex-col sm:max-w-[68%] ${isOwn ? 'items-end' : 'items-start'}`}>
|
<div className={`flex max-w-[82%] flex-col sm:max-w-[68%] ${isOwn ? 'items-end' : 'items-start'}`}>
|
||||||
{!isOwn && showName && (
|
{!isOwn && showName && <span className="mb-1 px-1 text-xs font-semibold text-muted">{name}</span>}
|
||||||
<span className="mb-1 px-1 text-xs font-semibold text-muted">{name}</span>
|
|
||||||
)}
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`absolute -top-9 z-10 hidden items-center gap-0.5 rounded-full border border-line bg-panel/95 px-1.5 py-1 shadow-xl shadow-black/40 backdrop-blur group-hover:flex group-focus-within:flex ${
|
||||||
|
isOwn ? 'right-0' : 'left-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{QUICK_EMOJIS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReact(message, emoji)}
|
||||||
|
aria-label={`Реакция ${emoji}`}
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-base transition hover:scale-110 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="mx-0.5 h-5 w-px bg-line" aria-hidden="true" />
|
||||||
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted" aria-hidden="true">
|
||||||
|
<IconSmile className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
@@ -43,7 +141,13 @@ export default function MessageBubble({ message, isOwn, sender, showAvatar, show
|
|||||||
isSending ? 'opacity-80' : '',
|
isSending ? 'opacity-80' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap break-words text-balance">{message.body}</p>
|
{media && (
|
||||||
|
<div className={message.body ? 'mb-2' : ''}>
|
||||||
|
{isImage ? <MediaImage media={media} /> : <MediaFile media={media} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.body && <p className="whitespace-pre-wrap break-words text-balance">{message.body}</p>}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mt-1 flex items-center justify-end gap-1 text-[10.5px] ${
|
className={`mt-1 flex items-center justify-end gap-1 text-[10.5px] ${
|
||||||
@@ -75,6 +179,31 @@ export default function MessageBubble({ message, isOwn, sender, showAvatar, show
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{grouped.length > 0 && (
|
||||||
|
<div className={`mt-1 flex flex-wrap gap-1 ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
{grouped.map(([emoji, users]) => {
|
||||||
|
const mine = users.includes(currentUserId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReact(message, emoji)}
|
||||||
|
aria-label={`Реакция ${emoji}: ${users.length}`}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition ${
|
||||||
|
mine
|
||||||
|
? 'border-accent/50 bg-accent-ghost text-accent'
|
||||||
|
: 'border-line bg-surface-2 text-muted hover:border-accent/40 hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm leading-none">{emoji}</span>
|
||||||
|
{users.length > 1 && <span className="font-semibold">{users.length}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isError && <span className="mt-1 px-1 text-[11px] text-danger">Не отправлено</span>}
|
{isError && <span className="mt-1 px-1 text-[11px] text-danger">Не отправлено</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface MessageListProps {
|
|||||||
error: string | null
|
error: string | null
|
||||||
onLoadOlder: () => void
|
onLoadOlder: () => void
|
||||||
onRetry: (message: ClientMessage) => void
|
onRetry: (message: ClientMessage) => void
|
||||||
|
onReact: (message: ClientMessage, emoji: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_WINDOW_MINUTES = 5
|
const GROUP_WINDOW_MINUTES = 5
|
||||||
@@ -27,6 +28,7 @@ export default function MessageList({
|
|||||||
error,
|
error,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onReact,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const firstIdRef = useRef<number | null>(null)
|
const firstIdRef = useRef<number | null>(null)
|
||||||
@@ -135,7 +137,9 @@ export default function MessageList({
|
|||||||
sender={directory.get(message.sender_id) ?? null}
|
sender={directory.get(message.sender_id) ?? null}
|
||||||
showAvatar={newGroup}
|
showAvatar={newGroup}
|
||||||
showName={newGroup}
|
showName={newGroup}
|
||||||
|
currentUserId={currentUser.id}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
onReact={onReact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import Avatar from './Avatar'
|
import Avatar from './Avatar'
|
||||||
import { IconClose, IconSearch, IconUserPlus } from './icons'
|
import { IconClose, IconSearch, IconUserPlus, IconUsers } from './icons'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { userTitle } from '../lib/format'
|
import { userTitle } from '../lib/format'
|
||||||
import type { Contact, User } from '../lib/types'
|
import type { Contact, User } from '../lib/types'
|
||||||
|
|
||||||
|
type DialogMode = 'dm' | 'group'
|
||||||
|
|
||||||
interface NewChatDialogProps {
|
interface NewChatDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onStartChat: (user: User) => Promise<void>
|
onStartChat: (user: User) => Promise<void>
|
||||||
|
onCreateGroup: (name: string, memberIds: number[]) => Promise<void>
|
||||||
onContactsChanged: () => void
|
onContactsChanged: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewChatDialog({ onClose, onStartChat, onContactsChanged }: NewChatDialogProps) {
|
export default function NewChatDialog({
|
||||||
|
onClose,
|
||||||
|
onStartChat,
|
||||||
|
onCreateGroup,
|
||||||
|
onContactsChanged,
|
||||||
|
}: NewChatDialogProps) {
|
||||||
|
const [mode, setMode] = useState<DialogMode>('dm')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [results, setResults] = useState<User[]>([])
|
const [results, setResults] = useState<User[]>([])
|
||||||
const [resultQuery, setResultQuery] = useState('')
|
const [resultQuery, setResultQuery] = useState('')
|
||||||
@@ -19,6 +28,9 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
|||||||
const [loadingContacts, setLoadingContacts] = useState(true)
|
const [loadingContacts, setLoadingContacts] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busyId, setBusyId] = useState<number | null>(null)
|
const [busyId, setBusyId] = useState<number | null>(null)
|
||||||
|
const [groupName, setGroupName] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,6 +118,32 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
|||||||
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 backdrop-blur-sm sm:items-center sm:p-6">
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 backdrop-blur-sm sm:items-center sm:p-6">
|
||||||
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
|
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
|
||||||
@@ -120,7 +158,9 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
|||||||
<h2 id="new-chat-title" className="font-display text-base font-semibold text-fg">
|
<h2 id="new-chat-title" className="font-display text-base font-semibold text-fg">
|
||||||
Новый чат
|
Новый чат
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted">Найдите человека или выберите из контактов</p>
|
<p className="text-xs text-muted">
|
||||||
|
{mode === 'dm' ? 'Найдите человека или выберите из контактов' : 'Соберите группу и добавьте участников'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -133,6 +173,39 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pt-4">
|
<div className="px-5 pt-4">
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-1 rounded-xl border border-line bg-surface/60 p-1">
|
||||||
|
{(['dm', 'group'] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
|
||||||
|
mode === value ? 'bg-accent-ghost text-accent ring-1 ring-accent/30' : 'text-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value === 'dm' ? 'Личный чат' : 'Группа'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'group' && (
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<IconUsers className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||||
|
<label htmlFor="group-name" className="sr-only">
|
||||||
|
Название группы
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="group-name"
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
|
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||||
<label htmlFor="new-chat-search" className="sr-only">
|
<label htmlFor="new-chat-search" className="sr-only">
|
||||||
@@ -170,13 +243,36 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
|||||||
{list.map((user) => {
|
{list.map((user) => {
|
||||||
const isContact = contactIds.has(user.id)
|
const isContact = contactIds.has(user.id)
|
||||||
const busy = busyId === user.id
|
const busy = busyId === user.id
|
||||||
|
const selected = selectedIds.has(user.id)
|
||||||
return (
|
return (
|
||||||
<li key={user.id} className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70">
|
<li key={user.id}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-xl px-2.5 py-2 transition ${
|
||||||
|
mode === 'group' && selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode === 'group' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleMember(user.id)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={selected ? 'Убрать из группы' : 'Добавить в группу'}
|
||||||
|
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
|
||||||
|
selected ? 'border-accent bg-accent text-white' : 'border-line bg-surface text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
|
||||||
|
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Avatar name={userTitle(user)} seed={user.id} size="md" />
|
<Avatar name={userTitle(user)} seed={user.id} size="md" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-fg">{userTitle(user)}</p>
|
<p className="truncate text-sm font-semibold text-fg">{userTitle(user)}</p>
|
||||||
<p className="truncate text-xs text-muted">@{user.username}</p>
|
<p className="truncate text-xs text-muted">@{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{mode === 'dm' && (
|
||||||
|
<>
|
||||||
{!isContact && (
|
{!isContact && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -196,12 +292,31 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
|||||||
>
|
>
|
||||||
{busy ? '…' : 'Написать'}
|
{busy ? '…' : 'Написать'}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mode === 'group' && (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-line/80 px-5 py-3">
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
Выбрано: <span className="font-semibold text-fg">{selectedIds.size}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCreateGroup()}
|
||||||
|
disabled={!canCreate}
|
||||||
|
className="rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-2 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{creating ? 'Создание…' : 'Создать группу'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import Avatar from './Avatar'
|
import Avatar, { GroupAvatar } from './Avatar'
|
||||||
import { IconLogout, IconPlus, IconSearch } from './icons'
|
import { IconLogout, IconPlus, IconSearch } from './icons'
|
||||||
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
|
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
|
||||||
import type { Conversation, User } from '../lib/types'
|
import type { Conversation, User } from '../lib/types'
|
||||||
@@ -21,9 +21,15 @@ interface SidebarProps {
|
|||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewOf(conversation: Conversation, currentUserId: number): string {
|
function previewOf(conversation: Conversation, currentUserId: number, directory: Map<number, User>): string {
|
||||||
const last = conversation.last_message
|
const last = conversation.last_message
|
||||||
if (!last) return 'Нет сообщений'
|
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 ? 'Вы: ' : ''
|
const prefix = last.sender_id === currentUserId ? 'Вы: ' : ''
|
||||||
return `${prefix}${last.body}`
|
return `${prefix}${last.body}`
|
||||||
}
|
}
|
||||||
@@ -50,9 +56,20 @@ export default function Sidebar({
|
|||||||
const term = query.trim().toLowerCase()
|
const term = query.trim().toLowerCase()
|
||||||
return conversations
|
return conversations
|
||||||
.map((conversation) => {
|
.map((conversation) => {
|
||||||
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
|
const isGroup = conversation.type === 'group'
|
||||||
const title = peer ? userTitle(peer) : `Чат #${conversation.id}`
|
const peer = isGroup ? null : peerForConversation(conversation, peers, directory, currentUser.id)
|
||||||
return { conversation, peer, title, preview: previewOf(conversation, 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))
|
.filter((item) => !term || item.title.toLowerCase().includes(term) || item.preview.toLowerCase().includes(term))
|
||||||
}, [conversations, query, peers, directory, currentUser.id])
|
}, [conversations, query, peers, directory, currentUser.id])
|
||||||
@@ -152,7 +169,7 @@ export default function Sidebar({
|
|||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{!loading &&
|
{!loading &&
|
||||||
!error &&
|
!error &&
|
||||||
items.map(({ conversation, peer, title, preview }) => {
|
items.map(({ conversation, peer, isGroup, title, preview }) => {
|
||||||
const selected = conversation.id === selectedId
|
const selected = conversation.id === selectedId
|
||||||
const isTyping = typing[conversation.id] === true
|
const isTyping = typing[conversation.id] === true
|
||||||
const unread = conversation.unread_count ?? 0
|
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'
|
selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{isGroup ? (
|
||||||
|
<GroupAvatar name={title} seed={conversation.id} size="lg" />
|
||||||
|
) : (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={title}
|
name={title}
|
||||||
seed={peer?.id ?? conversation.id}
|
seed={peer?.id ?? conversation.id}
|
||||||
size="lg"
|
size="lg"
|
||||||
online={peer ? presence[peer.id] === true : false}
|
online={peer ? presence[peer.id] === true : false}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-baseline justify-between gap-2">
|
<span className="flex items-baseline justify-between gap-2">
|
||||||
<span className={`truncate text-sm font-semibold ${selected ? 'text-fg' : 'text-fg/90'}`}>
|
<span className={`truncate text-sm font-semibold ${selected ? 'text-fg' : 'text-fg/90'}`}>
|
||||||
|
|||||||
@@ -108,3 +108,61 @@ export function IconUserPlus({ className }: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconUsers({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="9" cy="8.5" r="3.2" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path d="M3.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
<path d="M16 5.6a3.2 3.2 0 0 1 0 5.8M17.5 13.6A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPaperclip({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8.5 12.5 14 7a3 3 0 0 1 4.2 4.2l-6.6 6.6a5 5 0 0 1-7.1-7.1l6.4-6.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconFile({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M6 3.5h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M13 3.5v5h5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTrash({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
<path d="M6.5 7l.8 12a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4l.8-12" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||||
|
<path d="M10.5 11v6M13.5 11v6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSmile({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path d="M8.5 14.2a4.2 4.2 0 0 0 7 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
<path d="M9.3 9.5h.01M14.7 9.5h.01" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface UseConversationsResult {
|
|||||||
error: string | null
|
error: string | null
|
||||||
reload: () => Promise<void>
|
reload: () => Promise<void>
|
||||||
add: (conversation: Conversation) => void
|
add: (conversation: Conversation) => void
|
||||||
|
remove: (id: number) => void
|
||||||
patch: (id: number, updater: (conversation: Conversation) => Conversation) => 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))))
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface UseMessagesResult {
|
|||||||
error: string | null
|
error: string | null
|
||||||
loadOlder: () => Promise<void>
|
loadOlder: () => Promise<void>
|
||||||
upsert: (message: Message, tempId?: string) => void
|
upsert: (message: Message, tempId?: string) => void
|
||||||
|
patchMessage: (id: number, updater: (message: ClientMessage) => ClientMessage) => void
|
||||||
addOptimistic: (message: ClientMessage) => void
|
addOptimistic: (message: ClientMessage) => void
|
||||||
markSending: (tempId: string) => void
|
markSending: (tempId: string) => void
|
||||||
failOptimistic: (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) => {
|
const addOptimistic = useCallback((message: ClientMessage) => {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (message.temp_id && prev.some((item) => item.temp_id === message.temp_id)) return 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,
|
error,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
upsert,
|
upsert,
|
||||||
|
patchMessage,
|
||||||
addOptimistic,
|
addOptimistic,
|
||||||
markSending,
|
markSending,
|
||||||
failOptimistic,
|
failOptimistic,
|
||||||
|
|||||||
+83
-4
@@ -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'
|
const API_BASE = '/api/v1'
|
||||||
|
|
||||||
@@ -98,6 +98,29 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
return readJson<T>(res)
|
return readJson<T>(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestForm<T>(path: string, form: FormData, authRetry = true): Promise<T> {
|
||||||
|
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<T>(path, form, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(await readError(res), res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return readJson<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiClient {
|
export interface ApiClient {
|
||||||
register(username: string, displayName: string, password: string): Promise<User>
|
register(username: string, displayName: string, password: string): Promise<User>
|
||||||
login(username: string, password: string): Promise<User>
|
login(username: string, password: string): Promise<User>
|
||||||
@@ -110,8 +133,20 @@ export interface ApiClient {
|
|||||||
removeContact(userId: number): Promise<void>
|
removeContact(userId: number): Promise<void>
|
||||||
listConversations(): Promise<Conversation[]>
|
listConversations(): Promise<Conversation[]>
|
||||||
createDM(userId: number): Promise<Conversation>
|
createDM(userId: number): Promise<Conversation>
|
||||||
|
createGroup(name: string, memberIds: number[]): Promise<Conversation>
|
||||||
|
listMembers(conversationId: number): Promise<Member[]>
|
||||||
|
addMember(conversationId: number, userId: number): Promise<void>
|
||||||
|
removeMember(conversationId: number, userId: number): Promise<void>
|
||||||
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
|
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
|
||||||
sendMessage(conversationId: number, body: string, tempId: string): Promise<SendMessageResult>
|
sendMessage(
|
||||||
|
conversationId: number,
|
||||||
|
body: string,
|
||||||
|
tempId: string,
|
||||||
|
mediaId?: number,
|
||||||
|
): Promise<SendMessageResult>
|
||||||
|
addReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
||||||
|
removeReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
||||||
|
uploadMedia(file: File): Promise<Media>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api: ApiClient = {
|
export const api: ApiClient = {
|
||||||
@@ -167,17 +202,61 @@ export const api: ApiClient = {
|
|||||||
return request<Conversation>('/conversations', { method: 'POST', body: { user_id: userId } })
|
return request<Conversation>('/conversations', { method: 'POST', body: { user_id: userId } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createGroup(name, memberIds) {
|
||||||
|
return request<Conversation>('/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type: 'group', name, member_ids: memberIds },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
listMembers(conversationId) {
|
||||||
|
return request<Member[]>(`/conversations/${conversationId}/members`)
|
||||||
|
},
|
||||||
|
|
||||||
|
addMember(conversationId, userId) {
|
||||||
|
return request<void>(`/conversations/${conversationId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { user_id: userId },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMember(conversationId, userId) {
|
||||||
|
return request<void>(`/conversations/${conversationId}/members/${userId}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
listMessages(conversationId, before, limit = 50) {
|
listMessages(conversationId, before, limit = 50) {
|
||||||
const params = new URLSearchParams({ limit: String(limit) })
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
if (before !== undefined) params.set('before', String(before))
|
if (before !== undefined) params.set('before', String(before))
|
||||||
return request<Message[]>(`/conversations/${conversationId}/messages?${params.toString()}`)
|
return request<Message[]>(`/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<Message>(`/conversations/${conversationId}/messages`, {
|
const message = await request<Message>(`/conversations/${conversationId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { body, temp_id: tempId },
|
body: payload,
|
||||||
})
|
})
|
||||||
return { temp_id: tempId, message }
|
return { temp_id: tempId, message }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addReaction(conversationId, messageId, emoji) {
|
||||||
|
return request<Message>(`/conversations/${conversationId}/messages/${messageId}/reactions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { emoji },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeReaction(conversationId, messageId, emoji) {
|
||||||
|
return request<Message>(
|
||||||
|
`/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadMedia(file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return requestForm<Media>('/media', form)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ export function minutesBetween(a: string, b: string): number {
|
|||||||
return Math.abs(first.getTime() - second.getTime()) / 60000
|
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(
|
export function peerForConversation(
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
peers: Record<number, number>,
|
peers: Record<number, number>,
|
||||||
|
|||||||
+90
-1
@@ -11,11 +11,27 @@ export interface Contact {
|
|||||||
added_at: string
|
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 {
|
export interface Message {
|
||||||
id: number
|
id: number
|
||||||
conversation_id: number
|
conversation_id: number
|
||||||
sender_id: number
|
sender_id: number
|
||||||
body: string
|
body: string
|
||||||
|
reactions: Reaction[]
|
||||||
|
media?: Media | null
|
||||||
created_at: string
|
created_at: string
|
||||||
edited_at?: string | null
|
edited_at?: string | null
|
||||||
deleted_at?: string | null
|
deleted_at?: string | null
|
||||||
@@ -32,11 +48,21 @@ export interface ClientMessage extends Message {
|
|||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: number
|
id: number
|
||||||
type: string
|
type: string
|
||||||
|
name: string
|
||||||
|
avatar: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
last_message?: Message | null
|
last_message?: Message | null
|
||||||
unread_count?: number
|
unread_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MemberRole = 'owner' | 'member'
|
||||||
|
|
||||||
|
export interface Member {
|
||||||
|
user: User
|
||||||
|
role: MemberRole
|
||||||
|
joined_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendMessageResult {
|
export interface SendMessageResult {
|
||||||
temp_id: string
|
temp_id: string
|
||||||
message: Message
|
message: Message
|
||||||
@@ -51,6 +77,9 @@ export interface WsMessageCreated {
|
|||||||
sender_id: number
|
sender_id: number
|
||||||
body: string
|
body: string
|
||||||
created_at: 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
|
||||||
|
|||||||
+12
-1
@@ -14,7 +14,18 @@ interface OutgoingRead {
|
|||||||
data: { conversation_id: number; message_id: number }
|
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 {
|
function isWsEvent(value: unknown): value is WsEvent {
|
||||||
if (typeof value !== 'object' || value === null) return false
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAuth } from '../auth/AuthContext'
|
|||||||
import { useConversations } from '../hooks/useConversations'
|
import { useConversations } from '../hooks/useConversations'
|
||||||
import { useMessages } from '../hooks/useMessages'
|
import { useMessages } from '../hooks/useMessages'
|
||||||
import { useRealtime } from '../hooks/useRealtime'
|
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 {
|
interface ChatAppProps {
|
||||||
user: User
|
user: User
|
||||||
@@ -22,8 +22,9 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
const [typing, setTyping] = useState<Record<number, boolean>>({})
|
const [typing, setTyping] = useState<Record<number, boolean>>({})
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [focusTick, setFocusTick] = useState(0)
|
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 {
|
const {
|
||||||
messages,
|
messages,
|
||||||
loading: messagesLoading,
|
loading: messagesLoading,
|
||||||
@@ -32,6 +33,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
error: messagesError,
|
error: messagesError,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
upsert,
|
upsert,
|
||||||
|
patchMessage,
|
||||||
addOptimistic,
|
addOptimistic,
|
||||||
markSending,
|
markSending,
|
||||||
failOptimistic,
|
failOptimistic,
|
||||||
@@ -96,8 +98,16 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
(event: WsEvent) => {
|
(event: WsEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'message.created': {
|
case 'message.created': {
|
||||||
const { temp_id, conversation_id, message_id, sender_id, body, created_at } = event.data
|
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 }
|
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) {
|
if (conversation_id === selectedIdRef.current || sender_id === user.id) {
|
||||||
upsert(message, temp_id)
|
upsert(message, temp_id)
|
||||||
}
|
}
|
||||||
@@ -146,23 +156,64 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
if (conversation_id === selectedIdRef.current) markReadUpTo(message_id)
|
if (conversation_id === selectedIdRef.current) markReadUpTo(message_id)
|
||||||
break
|
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 realtime = useRealtime(handleEvent)
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (conversationId: number, body: string) => {
|
async (conversationId: number, body: string, media: Media | null) => {
|
||||||
const text = body.trim()
|
const text = body.trim()
|
||||||
if (!text) return
|
if (!text && !media) return
|
||||||
const tempId = crypto.randomUUID()
|
const tempId = crypto.randomUUID()
|
||||||
const optimistic: ClientMessage = {
|
const optimistic: ClientMessage = {
|
||||||
id: -Date.now(),
|
id: -Date.now(),
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
sender_id: user.id,
|
sender_id: user.id,
|
||||||
body: text,
|
body: text,
|
||||||
|
reactions: [],
|
||||||
|
media,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
temp_id: tempId,
|
temp_id: tempId,
|
||||||
status: 'sending',
|
status: 'sending',
|
||||||
@@ -170,7 +221,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
addOptimistic(optimistic)
|
addOptimistic(optimistic)
|
||||||
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
|
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
|
||||||
try {
|
try {
|
||||||
const result = await api.sendMessage(conversationId, text, tempId)
|
const result = await api.sendMessage(conversationId, text, tempId, media?.id)
|
||||||
upsert(result.message, tempId)
|
upsert(result.message, tempId)
|
||||||
patch(conversationId, (conversation) => ({
|
patch(conversationId, (conversation) => ({
|
||||||
...conversation,
|
...conversation,
|
||||||
@@ -190,7 +241,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
if (!tempId) return
|
if (!tempId) return
|
||||||
markSending(tempId)
|
markSending(tempId)
|
||||||
try {
|
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)
|
upsert(result.message, tempId)
|
||||||
patch(message.conversation_id, (conversation) => ({
|
patch(message.conversation_id, (conversation) => ({
|
||||||
...conversation,
|
...conversation,
|
||||||
@@ -204,6 +255,22 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
[failOptimistic, markSending, patch, upsert],
|
[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(
|
const sendTyping = useCallback(
|
||||||
(conversationId: number, isTyping: boolean) => {
|
(conversationId: number, isTyping: boolean) => {
|
||||||
realtime.sendTyping(conversationId, isTyping)
|
realtime.sendTyping(conversationId, isTyping)
|
||||||
@@ -233,6 +300,23 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
[add, loadContacts, rememberUser],
|
[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 lastMessage = messages.length > 0 ? messages[messages.length - 1] : null
|
||||||
const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null
|
const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null
|
||||||
|
|
||||||
@@ -264,6 +348,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
|
key={selectedId ?? 'empty'}
|
||||||
className={selectedId === null ? 'hidden md:flex' : 'flex'}
|
className={selectedId === null ? 'hidden md:flex' : 'flex'}
|
||||||
conversation={conversations.find((conversation) => conversation.id === selectedId) ?? null}
|
conversation={conversations.find((conversation) => conversation.id === selectedId) ?? null}
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
@@ -276,11 +361,15 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
error={messagesError}
|
error={messagesError}
|
||||||
|
membersVersion={membersVersion}
|
||||||
onBack={() => setSelectedId(null)}
|
onBack={() => setSelectedId(null)}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onTyping={sendTyping}
|
onTyping={sendTyping}
|
||||||
onLoadOlder={() => void loadOlder()}
|
onLoadOlder={() => void loadOlder()}
|
||||||
onRetry={resendMessage}
|
onRetry={resendMessage}
|
||||||
|
onReact={(message, emoji) => void handleReaction(message, emoji)}
|
||||||
|
onMembersChanged={() => setMembersVersion((version) => version + 1)}
|
||||||
|
onMembersLoaded={handleMembersLoaded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,6 +377,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
|||||||
<NewChatDialog
|
<NewChatDialog
|
||||||
onClose={() => setDialogOpen(false)}
|
onClose={() => setDialogOpen(false)}
|
||||||
onStartChat={handleStartChat}
|
onStartChat={handleStartChat}
|
||||||
|
onCreateGroup={handleCreateGroup}
|
||||||
onContactsChanged={() => void loadContacts()}
|
onContactsChanged={() => void loadContacts()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user