big update: added reactions, groups, media share
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"messenger/internal/realtime"
|
||||
@@ -13,19 +14,34 @@ import (
|
||||
type conversationJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastMessage *messageJSON `json:"last_message,omitempty"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
}
|
||||
|
||||
type memberJSON struct {
|
||||
User userJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
type messageJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
Reactions []reactionJSON `json:"reactions"`
|
||||
Media *mediaJSON `json:"media,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type reactionJSON struct {
|
||||
Emoji string `json:"emoji"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func toMessageJSON(m *store.Message) messageJSON {
|
||||
@@ -34,12 +50,25 @@ func toMessageJSON(m *store.Message) messageJSON {
|
||||
ConversationID: m.ConversationID,
|
||||
SenderID: m.SenderID,
|
||||
Body: m.Body,
|
||||
Reactions: []reactionJSON{},
|
||||
CreatedAt: m.CreatedAt,
|
||||
EditedAt: m.EditedAt,
|
||||
DeletedAt: m.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func attachReactions(msgs []messageJSON, reactions []store.Reaction) {
|
||||
byMessage := make(map[int64][]reactionJSON)
|
||||
for _, r := range reactions {
|
||||
byMessage[r.MessageID] = append(byMessage[r.MessageID], reactionJSON{Emoji: r.Emoji, UserID: r.UserID})
|
||||
}
|
||||
for i := range msgs {
|
||||
if rs, ok := byMessage[msgs[i].ID]; ok {
|
||||
msgs[i].Reactions = rs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
summaries, err := h.store.ListConversationsForUser(r.Context(), userID)
|
||||
@@ -52,6 +81,8 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
item := conversationJSON{
|
||||
ID: s.ID,
|
||||
Type: s.Type,
|
||||
Name: s.Name,
|
||||
Avatar: s.Avatar,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UnreadCount: s.UnreadCount,
|
||||
}
|
||||
@@ -64,12 +95,27 @@ func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
|
||||
type createConversationRequest struct {
|
||||
Type string `json:"type"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
MemberIDs []int64 `json:"member_ids"`
|
||||
}
|
||||
|
||||
func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
var req createConversationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
||||
|
||||
if req.Type == "group" || req.Name != "" {
|
||||
h.createGroup(w, r, userID, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.UserID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
@@ -82,7 +128,148 @@ func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, conversationJSON{ID: conv.ID, Type: conv.Type, CreatedAt: conv.CreatedAt})
|
||||
writeJSON(w, http.StatusCreated, conversationJSON{
|
||||
ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *apiHandler) createGroup(w http.ResponseWriter, r *http.Request, userID int64, req createConversationRequest) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len(name) > 64 {
|
||||
writeError(w, http.StatusBadRequest, "name must be 1-64 chars")
|
||||
return
|
||||
}
|
||||
for _, uid := range req.MemberIDs {
|
||||
if uid == userID {
|
||||
continue
|
||||
}
|
||||
if _, err := h.store.GetUserByID(r.Context(), uid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid member_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
conv, err := h.store.CreateGroup(r.Context(), userID, name, req.MemberIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.Subscribe(userID, conv.ID)
|
||||
for _, uid := range req.MemberIDs {
|
||||
if uid == userID {
|
||||
continue
|
||||
}
|
||||
h.hub.Subscribe(uid, conv.ID)
|
||||
h.hub.SendToUser(uid, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: conv.ID})
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, conversationJSON{
|
||||
ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *apiHandler) listMembers(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return
|
||||
}
|
||||
if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return
|
||||
}
|
||||
members, err := h.store.ListMembers(r.Context(), convID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
for _, m := range members {
|
||||
out = append(out, memberJSON{User: toUserJSON(&m.User), Role: m.Role, JoinedAt: m.JoinedAt})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *apiHandler) addMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return
|
||||
}
|
||||
if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return
|
||||
}
|
||||
conv, err := h.store.GetConversationByID(r.Context(), convID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "conversation not found")
|
||||
return
|
||||
}
|
||||
if conv.Type != "group" {
|
||||
writeError(w, http.StatusBadRequest, "only groups support members")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
if _, err := h.store.GetUserByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if err := h.store.AddMember(r.Context(), convID, req.UserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.Subscribe(req.UserID, convID)
|
||||
h.hub.SendToUser(req.UserID, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: convID})
|
||||
h.hub.PublishMemberChanged(convID, req.UserID, true)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *apiHandler) removeMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := userIDFrom(r.Context())
|
||||
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || convID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
|
||||
if err != nil || targetID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
|
||||
requesterRole, err := h.store.MemberRole(r.Context(), convID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member")
|
||||
return
|
||||
}
|
||||
targetRole, err := h.store.MemberRole(r.Context(), convID, targetID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
if targetRole == "owner" {
|
||||
writeError(w, http.StatusBadRequest, "cannot remove the owner")
|
||||
return
|
||||
}
|
||||
if userID != targetID && requesterRole != "owner" {
|
||||
writeError(w, http.StatusForbidden, "only the owner can remove others")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveMember(r.Context(), convID, targetID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
h.hub.Unsubscribe(targetID, convID)
|
||||
h.hub.SendToUser(targetID, realtime.EventConversationRemoved, realtime.ConversationChanged{ConversationID: convID})
|
||||
h.hub.PublishMemberChanged(convID, targetID, false)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -115,15 +302,46 @@ func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
out := make([]messageJSON, 0, len(messages))
|
||||
msgIDs := make([]int64, 0, len(messages))
|
||||
mediaIDs := make(map[int64]struct{})
|
||||
for i := range messages {
|
||||
out = append(out, toMessageJSON(&messages[i]))
|
||||
msgIDs = append(msgIDs, messages[i].ID)
|
||||
if messages[i].MediaID != nil {
|
||||
mediaIDs[*messages[i].MediaID] = struct{}{}
|
||||
}
|
||||
}
|
||||
reactions, err := h.store.ReactionsForMessages(r.Context(), msgIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
attachReactions(out, reactions)
|
||||
|
||||
mediaByID := make(map[int64]mediaJSON)
|
||||
for id := range mediaIDs {
|
||||
m, err := h.store.GetMediaByID(r.Context(), id)
|
||||
if err == nil {
|
||||
mediaByID[id] = toMediaJSON(m)
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
for j := range messages {
|
||||
if messages[j].ID == out[i].ID && messages[j].MediaID != nil {
|
||||
if md, ok := mediaByID[*messages[j].MediaID]; ok {
|
||||
out[i].Media = &md
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
Body string `json:"body"`
|
||||
TempID string `json:"temp_id"`
|
||||
Body string `json:"body"`
|
||||
TempID string `json:"temp_id"`
|
||||
MediaID *int64 `json:"media_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -144,8 +362,8 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Body == "" {
|
||||
writeError(w, http.StatusBadRequest, "body is required")
|
||||
if req.Body == "" && req.MediaID == nil {
|
||||
writeError(w, http.StatusBadRequest, "body or media_id is required")
|
||||
return
|
||||
}
|
||||
if len(req.Body) > 4000 {
|
||||
@@ -153,20 +371,50 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body)
|
||||
var attached *store.Media
|
||||
if req.MediaID != nil {
|
||||
attached, err = h.store.GetMediaByID(r.Context(), *req.MediaID)
|
||||
if err != nil || attached.UploaderID != userID {
|
||||
writeError(w, http.StatusBadRequest, "invalid media_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body, req.MediaID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
h.hub.PublishMessageCreated(convID, realtime.MessageCreated{
|
||||
wsEvent := realtime.MessageCreated{
|
||||
TempID: req.TempID,
|
||||
ConversationID: convID,
|
||||
MessageID: msg.ID,
|
||||
SenderID: userID,
|
||||
Body: msg.Body,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
})
|
||||
}
|
||||
if attached != nil {
|
||||
wsEvent.MediaID = &attached.ID
|
||||
info := toRealtimeMedia(attached)
|
||||
wsEvent.Media = &info
|
||||
}
|
||||
h.hub.PublishMessageCreated(convID, wsEvent)
|
||||
|
||||
writeJSON(w, http.StatusCreated, toMessageJSON(msg))
|
||||
out := toMessageJSON(msg)
|
||||
if attached != nil {
|
||||
md := toMediaJSON(attached)
|
||||
out.Media = &md
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func toRealtimeMedia(m *store.Media) realtime.MediaInfo {
|
||||
return realtime.MediaInfo{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
ContentType: m.ContentType,
|
||||
Size: m.Size,
|
||||
URL: "/api/v1/media/" + strconv.FormatInt(m.ID, 10),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user