feat: messaging with conversations, rich text, attachments, typing, unread (phase 9)
- conversations: dm (unique pair, find-or-create), group (titled), project (customer-bound); participant-only access - messages: server-sanitized rich text, attachments referencing uploads from the generic POST /api/v1/files endpoint (rate limited, MIME sniffed) - unread counts via aggregation; mark-read pushes readBy receipts - chat files served only to conversation participants (or uploader) - @mentions in chat notify the mentioned participant - typing indicator: client WS event relayed to other participants - WS targeted delivery (SendTo) + 15s polling fallback - two-pane messages UI: conversation list with unread badges, composer (contenteditable + formatting buttons), drag & drop upload, inline image previews with lightbox, new-conversation dialog with user search Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,4 @@
|
||||
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
|
||||
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
|
||||
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
|
||||
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
|
||||
|
||||
@@ -86,6 +86,8 @@ func run() error {
|
||||
})
|
||||
srv.SetWSHandler(hub.Handle)
|
||||
srv.SetPublishFn(hub.Broadcast)
|
||||
srv.SetSendTo(hub.SendTo)
|
||||
hub.SetInbound(srv.HandleInboundWS)
|
||||
|
||||
// Atomizer client honoring the admin base-URL override per call.
|
||||
atomClient := atomize.New(func() string {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/chat"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ws"
|
||||
)
|
||||
|
||||
func (s *Server) routesMessages(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/conversations", s.requireAuth(http.HandlerFunc(s.handleListConversations)))
|
||||
mux.Handle("POST /api/v1/conversations", s.authed(s.handleCreateConversation))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||
}
|
||||
|
||||
// conversation loads + authorizes participant access.
|
||||
func (s *Server) conversation(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||
conv, err := s.store.ConversationByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "conversation not found")
|
||||
} else {
|
||||
s.internalError(w, r, "load conversation", err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if !conv.HasParticipant(CurrentUser(r.Context()).ID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "you are not in this conversation")
|
||||
return nil, false
|
||||
}
|
||||
return conv, true
|
||||
}
|
||||
|
||||
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list conversations", err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
ids[i] = c.ID
|
||||
}
|
||||
unread, err := s.store.UnreadCounts(r.Context(), me.ID, ids)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "unread counts", err)
|
||||
return
|
||||
}
|
||||
// resolve display names for the list
|
||||
out := make([]map[string]any, len(convs))
|
||||
for i, c := range convs {
|
||||
title := c.Title
|
||||
if c.Kind == "dm" {
|
||||
for _, pid := range c.ParticipantIDs {
|
||||
if pid != me.ID {
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
title = u.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Conversation"
|
||||
}
|
||||
out[i] = map[string]any{
|
||||
"id": c.ID, "kind": c.Kind, "title": title,
|
||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
CustomerID string `json:"customerId"`
|
||||
ParticipantIDs []string `json:"participantIds"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
|
||||
// validate participants exist
|
||||
seen := map[string]bool{me.ID: true}
|
||||
participants := []string{me.ID}
|
||||
for _, id := range req.ParticipantIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.UserByID(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_participant", "unknown user "+id)
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
participants = append(participants, id)
|
||||
}
|
||||
|
||||
switch req.Kind {
|
||||
case "dm":
|
||||
if len(participants) != 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "dm needs exactly one other participant")
|
||||
return
|
||||
}
|
||||
conv, err := s.store.FindOrCreateDM(r.Context(), participants[0], participants[1])
|
||||
if err != nil {
|
||||
s.internalError(w, r, "create dm", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv})
|
||||
return
|
||||
case "group", "project":
|
||||
if len(participants) < 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "need at least one other participant")
|
||||
return
|
||||
}
|
||||
conv := &store.Conversation{
|
||||
Kind: req.Kind,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
ParticipantIDs: participants,
|
||||
}
|
||||
if req.Kind == "project" {
|
||||
if req.CustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "project conversations need customerId")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CustomerByID(r.Context(), req.CustomerID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_customer", "unknown customer")
|
||||
return
|
||||
}
|
||||
conv.CustomerID = req.CustomerID
|
||||
}
|
||||
if conv.Title == "" && req.Kind == "group" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group conversations need a title")
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateConversation(r.Context(), conv); err != nil {
|
||||
s.internalError(w, r, "create conversation", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conv})
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "kind must be dm, group or project")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
msgs, err := s.store.ListMessages(r.Context(), conv.ID, q.Get("cursor"),
|
||||
atoiDefault(q.Get("limit"), 50))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list messages", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
||||
}
|
||||
|
||||
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
Attachments []string `json:"attachments"` // fileIds from POST /api/v1/files
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
body := chat.SanitizeHTML(req.Body)
|
||||
if strings.TrimSpace(chat.SanitizePlain(body)) == "" && len(req.Attachments) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_message", "message needs text or attachments")
|
||||
return
|
||||
}
|
||||
|
||||
atts := []store.MessageAttachment{}
|
||||
for _, fid := range req.Attachments {
|
||||
rc, meta, err := s.files.Open(r.Context(), fid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_attachment", "unknown file "+fid)
|
||||
return
|
||||
}
|
||||
rc.Close()
|
||||
if meta.OwnerID != me.ID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "attachment was uploaded by someone else")
|
||||
return
|
||||
}
|
||||
atts = append(atts, store.MessageAttachment{
|
||||
FileID: meta.ID, Name: meta.Name, MimeType: meta.MimeType,
|
||||
Size: meta.Size, IsImage: strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
msg := &store.Message{ConversationID: conv.ID, SenderID: me.ID, Body: body, Attachments: atts}
|
||||
if err := s.store.InsertMessage(r.Context(), msg); err != nil {
|
||||
s.internalError(w, r, "insert message", err)
|
||||
return
|
||||
}
|
||||
s.metrics.Inc("messages_sent_total", 1)
|
||||
|
||||
// live delivery to all participants + mention notifications (§11.1)
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||
}
|
||||
plain := chat.SanitizePlain(body)
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid == me.ID {
|
||||
continue
|
||||
}
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
lower := strings.ToLower(plain)
|
||||
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
|
||||
s.notifyUser(r.Context(), pid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := s.store.MarkConversationRead(r.Context(), conv.ID, CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "mark read", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
||||
}
|
||||
|
||||
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
if !s.loginLimiter.Allow("upload|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many uploads, slow down")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
scope := files.ScopeChat
|
||||
if r.FormValue("scope") == files.ScopeTask && (me.Roles.Consultant || me.Roles.Admin) {
|
||||
scope = files.ScopeTask
|
||||
}
|
||||
meta, err := s.files.Save(r.Context(), scope, me.ID, header.Filename,
|
||||
header.Header.Get("Content-Type"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, files.ErrTooLarge) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "save upload", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"fileId": meta.ID, "name": meta.Name, "mimeType": meta.MimeType,
|
||||
"size": meta.Size, "isImage": strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSearchUsers is the small directory for picking conversation
|
||||
// participants (§11.10).
|
||||
func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
filter := bson.M{"disabled": false}
|
||||
if q != "" {
|
||||
filter["$or"] = []bson.M{
|
||||
{"email": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
{"name": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
}
|
||||
}
|
||||
cur, err := s.store.DB.Collection("users").Find(r.Context(), filter,
|
||||
options.Find().SetLimit(20).SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search users", err)
|
||||
return
|
||||
}
|
||||
var users []domain.User
|
||||
if err := cur.All(r.Context(), &users); err != nil {
|
||||
s.internalError(w, r, "decode users", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, map[string]any{
|
||||
"id": u.ID, "name": u.Name, "email": u.Email, "avatarFileId": u.AvatarFileID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &data); err != nil || data.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conv, err := s.store.ConversationByID(ctx, data.ConversationID)
|
||||
if err != nil || !conv.HasParticipant(userID) {
|
||||
return
|
||||
}
|
||||
others := []string{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid != userID {
|
||||
others = append(others, pid)
|
||||
}
|
||||
}
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(others, "chat", "typing", map[string]string{
|
||||
"conversationId": conv.ID, "userId": userID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//go:build integration
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
func TestMessagingFlow(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
|
||||
a := newClient(t)
|
||||
userA := registerUser(t, ts.URL, a, "alice@example.com", "Alice A")
|
||||
aCSRF := csrfFrom(t, a, ts.URL)
|
||||
b := newClient(t)
|
||||
userB := registerUser(t, ts.URL, b, "bob@example.com", "Bob B")
|
||||
bCSRF := csrfFrom(t, b, ts.URL)
|
||||
c := newClient(t)
|
||||
registerUser(t, ts.URL, c, "carol@example.com", "Carol C")
|
||||
|
||||
// Alice opens a DM with Bob
|
||||
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "dm", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusOK)
|
||||
var created struct {
|
||||
Conversation store.Conversation `json:"conversation"`
|
||||
}
|
||||
bodyJSON(t, resp, &created)
|
||||
convID := created.Conversation.ID
|
||||
|
||||
// dm is unique: creating again returns the same conversation
|
||||
resp = mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "dm", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusOK)
|
||||
var again struct {
|
||||
Conversation store.Conversation `json:"conversation"`
|
||||
}
|
||||
bodyJSON(t, resp, &again)
|
||||
if again.Conversation.ID != convID {
|
||||
t.Fatalf("dm not deduplicated: %s vs %s", again.Conversation.ID, convID)
|
||||
}
|
||||
|
||||
// Alice sends a rich-text message; script is sanitized away
|
||||
resp = mustPost(t, a, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
|
||||
"body": `<p>Hi <b>Bob</b>!</p><script>alert(1)</script>`,
|
||||
}, aCSRF, http.StatusCreated)
|
||||
var sent struct {
|
||||
Message store.Message `json:"message"`
|
||||
}
|
||||
bodyJSON(t, resp, &sent)
|
||||
if strings.Contains(sent.Message.Body, "<script") || !strings.Contains(sent.Message.Body, "<b>Bob</b>") {
|
||||
t.Fatalf("sanitization wrong: %q", sent.Message.Body)
|
||||
}
|
||||
|
||||
// Bob sees 1 unread; Carol sees nothing
|
||||
resp, err := b.Get(ts.URL + "/api/v1/conversations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bobConvs struct {
|
||||
Conversations []map[string]any `json:"conversations"`
|
||||
}
|
||||
bodyJSON(t, resp, &bobConvs)
|
||||
if len(bobConvs.Conversations) != 1 {
|
||||
t.Fatalf("bob conversations: %d", len(bobConvs.Conversations))
|
||||
}
|
||||
if got := bobConvs.Conversations[0]["unread"].(float64); got != 1 {
|
||||
t.Fatalf("bob unread = %v, want 1", got)
|
||||
}
|
||||
if title := bobConvs.Conversations[0]["title"]; title != "Alice A" {
|
||||
t.Fatalf("dm title for bob = %v", title)
|
||||
}
|
||||
|
||||
// Carol can't read or post
|
||||
cResp, err := c.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cResp.Body.Close()
|
||||
if cResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("carol read: %d", cResp.StatusCode)
|
||||
}
|
||||
|
||||
// Bob reads → unread 0
|
||||
mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/read", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
|
||||
resp, _ = b.Get(ts.URL + "/api/v1/conversations")
|
||||
bodyJSON(t, resp, &bobConvs)
|
||||
if got := bobConvs.Conversations[0]["unread"].(float64); got != 0 {
|
||||
t.Fatalf("bob unread after read = %v", got)
|
||||
}
|
||||
|
||||
// Bob replies with a file attachment
|
||||
up := uploadChatFile(t, b, ts.URL, bCSRF, "shot.png", pngBytes)
|
||||
resp = mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
|
||||
"body": "<p>see attached</p>", "attachments": []string{up},
|
||||
}, bCSRF, http.StatusCreated)
|
||||
bodyJSON(t, resp, &sent)
|
||||
if len(sent.Message.Attachments) != 1 || !sent.Message.Attachments[0].IsImage {
|
||||
t.Fatalf("attachment: %+v", sent.Message.Attachments)
|
||||
}
|
||||
|
||||
// Alice (participant) can fetch the chat file; Carol cannot
|
||||
fResp, _ := a.Get(ts.URL + "/files/" + up)
|
||||
fResp.Body.Close()
|
||||
if fResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("alice chat file: %d", fResp.StatusCode)
|
||||
}
|
||||
fResp, _ = c.Get(ts.URL + "/files/" + up)
|
||||
fResp.Body.Close()
|
||||
if fResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("carol chat file: %d, want 403", fResp.StatusCode)
|
||||
}
|
||||
|
||||
// history pagination shape: both messages, chronological
|
||||
resp, _ = a.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
|
||||
var hist struct {
|
||||
Messages []store.Message `json:"messages"`
|
||||
}
|
||||
bodyJSON(t, resp, &hist)
|
||||
if len(hist.Messages) != 2 || hist.Messages[0].SenderID != userA.ID {
|
||||
t.Fatalf("history: %d messages, first by %s", len(hist.Messages), hist.Messages[0].SenderID)
|
||||
}
|
||||
|
||||
// group conversation requires a title
|
||||
resp = postJSON(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "group", "participantIds": []string{userB.ID},
|
||||
}, aCSRF)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("untitled group: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "group", "title": "Standup", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusCreated).Body.Close()
|
||||
}
|
||||
|
||||
func uploadChatFile(t *testing.T, c *http.Client, ts, csrf, name string, content []byte) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write(content)
|
||||
mw.Close()
|
||||
req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/files", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set(csrfHeader, csrf)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload: %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
FileID string `json:"fileId"`
|
||||
}
|
||||
bodyJSON(t, resp, &out)
|
||||
return out.FileID
|
||||
}
|
||||
|
||||
func TestMentionInChatNotifies(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
a := newClient(t)
|
||||
registerUser(t, ts.URL, a, "ma@example.com", "Mention Alice")
|
||||
aCSRF := csrfFrom(t, a, ts.URL)
|
||||
b := newClient(t)
|
||||
userB := registerUser(t, ts.URL, b, "mb@example.com", "Mention Bob")
|
||||
|
||||
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
|
||||
"kind": "dm", "participantIds": []string{userB.ID},
|
||||
}, aCSRF, http.StatusOK)
|
||||
var created struct {
|
||||
Conversation store.Conversation `json:"conversation"`
|
||||
}
|
||||
bodyJSON(t, resp, &created)
|
||||
|
||||
mustPost(t, a, ts.URL+"/api/v1/conversations/"+created.Conversation.ID+"/messages",
|
||||
map[string]any{"body": "<p>hey @mention, look at this</p>"}, aCSRF, http.StatusCreated).Body.Close()
|
||||
|
||||
notifs, err := st.ListNotifications(t.Context(), userB.ID, false, "", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mentioned bool
|
||||
for _, n := range notifs {
|
||||
if n.Kind == "mention" {
|
||||
mentioned = true
|
||||
}
|
||||
}
|
||||
if !mentioned {
|
||||
t.Fatal("bob was not notified about the mention")
|
||||
}
|
||||
}
|
||||
@@ -193,9 +193,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
||||
switch meta.Scope {
|
||||
case files.ScopeAvatar:
|
||||
allowed = true // avatars are visible to all logged-in users
|
||||
default:
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
|
||||
user.Roles.Consultant // scoped tighter as chat/task features land
|
||||
case files.ScopeChat:
|
||||
// uploader always; otherwise participant of the conversation the
|
||||
// file is attached to
|
||||
allowed = meta.OwnerID == user.ID
|
||||
if !allowed {
|
||||
ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "chat file access", err)
|
||||
return
|
||||
}
|
||||
allowed = ok
|
||||
}
|
||||
default: // task scope: owner, admins, consultants, or the assignee
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
|
||||
if !allowed && user.Roles.Developer {
|
||||
// developers can fetch task files for tasks they can see;
|
||||
// signed URLs cover the external-service path
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
|
||||
|
||||
@@ -42,9 +42,15 @@ type Server struct {
|
||||
publishFn func(channel, event string, payload any)
|
||||
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
|
||||
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
|
||||
sendTo func(userIDs []string, channel, event string, payload any)
|
||||
performerClient *workperform.Client
|
||||
}
|
||||
|
||||
// SetSendTo wires targeted hub delivery (chat messages, typing).
|
||||
func (s *Server) SetSendTo(f func(userIDs []string, channel, event string, payload any)) {
|
||||
s.sendTo = f
|
||||
}
|
||||
|
||||
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
|
||||
func (s *Server) SetPerformerClient(c *workperform.Client) {
|
||||
s.performerClient = c
|
||||
@@ -81,6 +87,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
||||
s.routesBoard(mux)
|
||||
s.routesConsultant(mux)
|
||||
s.routesWorkResults(mux)
|
||||
s.routesMessages(mux)
|
||||
mux.HandleFunc("GET /ws", s.handleWS)
|
||||
s.routesWeb(mux)
|
||||
|
||||
|
||||
@@ -219,9 +219,10 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
}))
|
||||
|
||||
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
||||
|
||||
// Placeholders for areas built in later phases; replaced as they land.
|
||||
placeholders := map[string]string{
|
||||
"/messages": "Messages",
|
||||
"/metrics": "Metrics",
|
||||
}
|
||||
for path, title := range placeholders {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
// Conversation per §4.6.
|
||||
type Conversation struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
||||
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
}
|
||||
|
||||
// MessageAttachment per §4.6.
|
||||
type MessageAttachment struct {
|
||||
FileID string `bson:"fileId" json:"fileId"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
MimeType string `bson:"mimeType" json:"mimeType"`
|
||||
Size int64 `bson:"size" json:"size"`
|
||||
IsImage bool `bson:"isImage" json:"isImage"`
|
||||
}
|
||||
|
||||
type ReadReceipt struct {
|
||||
UserID string `bson:"userId" json:"userId"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
ConversationID string `bson:"conversationId" json:"conversationId"`
|
||||
SenderID string `bson:"senderId" json:"senderId"`
|
||||
Body string `bson:"body" json:"body"` // sanitized rich text
|
||||
Attachments []MessageAttachment `bson:"attachments" json:"attachments"`
|
||||
ReadBy []ReadReceipt `bson:"readBy" json:"readBy"`
|
||||
EditedAt *time.Time `bson:"editedAt" json:"editedAt"`
|
||||
DeletedAt *time.Time `bson:"deletedAt" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (c *Conversation) HasParticipant(userID string) bool {
|
||||
for _, id := range c.ParticipantIDs {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindOrCreateDM returns the unique dm between two users, creating it on
|
||||
// first contact (§4.6: participantIds is the dm unique key).
|
||||
func (s *Store) FindOrCreateDM(ctx context.Context, a, b string) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{
|
||||
"kind": "dm",
|
||||
"participantIds": bson.M{"$all": []string{a, b}, "$size": 2},
|
||||
}).Decode(&conv)
|
||||
if err == nil {
|
||||
return &conv, nil
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, fmt.Errorf("find dm: %w", err)
|
||||
}
|
||||
conv = Conversation{
|
||||
ID: ulid.New(), Kind: "dm", ParticipantIDs: []string{a, b},
|
||||
LastMessageAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := s.DB.Collection("conversations").InsertOne(ctx, conv); err != nil {
|
||||
return nil, fmt.Errorf("create dm: %w", err)
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateConversation(ctx context.Context, c *Conversation) error {
|
||||
c.ID = ulid.New()
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
c.LastMessageAt = c.CreatedAt
|
||||
if _, err := s.DB.Collection("conversations").InsertOne(ctx, c); err != nil {
|
||||
return fmt.Errorf("create conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation, error) {
|
||||
var c Conversation
|
||||
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{"_id": id}).Decode(&c)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find conversation: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||
bson.M{"participantIds": userID},
|
||||
options.Find().SetSort(bson.D{{Key: "lastMessageAt", Value: -1}}).SetLimit(100))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list conversations: %w", err)
|
||||
}
|
||||
out := []Conversation{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode conversations: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnreadCounts returns per-conversation unread message counts for the user.
|
||||
func (s *Store) UnreadCounts(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
cur, err := s.DB.Collection("messages").Aggregate(ctx, mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.M{
|
||||
"conversationId": bson.M{"$in": convIDs},
|
||||
"senderId": bson.M{"$ne": userID},
|
||||
"deletedAt": nil,
|
||||
"readBy.userId": bson.M{"$ne": userID},
|
||||
}}},
|
||||
{{Key: "$group", Value: bson.M{"_id": "$conversationId", "n": bson.M{"$sum": 1}}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unread counts: %w", err)
|
||||
}
|
||||
var rows []struct {
|
||||
ID string `bson:"_id"`
|
||||
N int64 `bson:"n"`
|
||||
}
|
||||
if err := cur.All(ctx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("decode unread: %w", err)
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r.N
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertMessage stores the message and bumps the conversation timestamp.
|
||||
func (s *Store) InsertMessage(ctx context.Context, m *Message) error {
|
||||
m.ID = ulid.New()
|
||||
if m.Attachments == nil {
|
||||
m.Attachments = []MessageAttachment{}
|
||||
}
|
||||
m.ReadBy = []ReadReceipt{{UserID: m.SenderID, At: time.Now().UTC()}}
|
||||
if _, err := s.DB.Collection("messages").InsertOne(ctx, m); err != nil {
|
||||
return fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": m.ConversationID},
|
||||
bson.M{"$set": bson.M{"lastMessageAt": time.Now().UTC()}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bump conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMessages returns up to limit messages older than cursor (ULID order),
|
||||
// newest last.
|
||||
func (s *Store) ListMessages(ctx context.Context, convID, cursor string, limit int) ([]Message, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
q := bson.M{"conversationId": convID}
|
||||
if cursor != "" {
|
||||
q["_id"] = bson.M{"$lt": cursor}
|
||||
}
|
||||
cur, err := s.DB.Collection("messages").Find(ctx, q,
|
||||
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages: %w", err)
|
||||
}
|
||||
out := []Message{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode messages: %w", err)
|
||||
}
|
||||
// reverse to chronological order
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkConversationRead adds a read receipt to every unread message.
|
||||
func (s *Store) MarkConversationRead(ctx context.Context, convID, userID string) (int64, error) {
|
||||
res, err := s.DB.Collection("messages").UpdateMany(ctx,
|
||||
bson.M{
|
||||
"conversationId": convID,
|
||||
"readBy.userId": bson.M{"$ne": userID},
|
||||
},
|
||||
bson.M{"$push": bson.M{"readBy": ReadReceipt{UserID: userID, At: time.Now().UTC()}}})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mark read: %w", err)
|
||||
}
|
||||
return res.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// UserCanAccessChatFile checks the file is attached to a message in one of
|
||||
// the user's conversations.
|
||||
func (s *Store) UserCanAccessChatFile(ctx context.Context, userID, fileID string) (bool, error) {
|
||||
var msg Message
|
||||
err := s.DB.Collection("messages").FindOne(ctx,
|
||||
bson.M{"attachments.fileId": fileID}).Decode(&msg)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("find file message: %w", err)
|
||||
}
|
||||
conv, err := s.ConversationByID(ctx, msg.ConversationID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return conv.HasParticipant(userID), nil
|
||||
}
|
||||
@@ -155,6 +155,42 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
|
||||
/* chat */
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; }
|
||||
.chat-sidebar { overflow: auto; }
|
||||
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.conv-list li { border-bottom: 1px solid var(--border); }
|
||||
.conv-list li.active .conv-item { background: var(--surface2); }
|
||||
.conv-item {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 10px 8px; font: inherit; text-align: left;
|
||||
background: none; border: none; color: var(--text); cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.conv-item:hover { background: var(--surface2); }
|
||||
.chat-main { display: flex; flex-direction: column; }
|
||||
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
|
||||
.chat-msg {
|
||||
max-width: 75%; margin: 8px 0; padding: 8px 12px;
|
||||
background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.chat-msg.own { margin-left: auto; background: color-mix(in srgb, var(--accent) 12%, var(--surface2)); }
|
||||
.chat-msg p, .chat-msg ul, .chat-msg ol { margin: 4px 0; }
|
||||
.chat-img { max-width: 240px; max-height: 180px; cursor: zoom-in; border-radius: var(--radius); }
|
||||
.chat-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.chat-composer {
|
||||
min-height: 60px; max-height: 200px; overflow: auto;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px;
|
||||
}
|
||||
.chat-composer:empty::before { content: attr(data-placeholder); color: var(--muted); }
|
||||
.chat-attachments { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.lightbox { border: none; background: transparent; max-width: 92vw; max-height: 92vh; }
|
||||
.lightbox img { max-width: 90vw; max-height: 88vh; cursor: zoom-out; }
|
||||
.lightbox::backdrop { background: rgba(0,0,0,0.8); }
|
||||
@media (max-width: 800px) { .chat-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* kanban */
|
||||
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.kanban-col {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const convList = document.getElementById('conv-list');
|
||||
const msgHost = document.getElementById('chat-messages');
|
||||
const form = document.getElementById('chat-form');
|
||||
const composer = document.getElementById('chat-composer');
|
||||
|
||||
let meId = '';
|
||||
let conversations = [];
|
||||
let current = null;
|
||||
let oldestCursor = '';
|
||||
let pendingAttachments = [];
|
||||
const userCache = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function userName(id) {
|
||||
if (!userCache.has(id)) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${id}/card`);
|
||||
userCache.set(id, res.card.name);
|
||||
} catch (e) { userCache.set(id, '…'); }
|
||||
}
|
||||
return userCache.get(id);
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/conversations');
|
||||
conversations = res.conversations;
|
||||
renderConvList();
|
||||
const want = new URLSearchParams(location.search).get('c');
|
||||
if (!current && want) {
|
||||
const c = conversations.find((x) => x.id === want);
|
||||
if (c) openConversation(c);
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function renderConvList() {
|
||||
convList.replaceChildren(...conversations.map((c) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = current && current.id === c.id ? 'active' : '';
|
||||
li.innerHTML = `
|
||||
<button type="button" class="conv-item">
|
||||
<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
|
||||
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}
|
||||
</button>`;
|
||||
li.querySelector('button').addEventListener('click', () => openConversation(c));
|
||||
return li;
|
||||
}));
|
||||
if (!conversations.length) {
|
||||
convList.innerHTML = '<li class="muted">No conversations yet.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(c) {
|
||||
current = c;
|
||||
oldestCursor = '';
|
||||
document.getElementById('chat-title').textContent = c.title;
|
||||
form.hidden = false;
|
||||
msgHost.replaceChildren();
|
||||
renderConvList();
|
||||
await loadMessages(false);
|
||||
await api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
|
||||
c.unread = 0;
|
||||
renderConvList();
|
||||
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||
}
|
||||
|
||||
async function loadMessages(prepend) {
|
||||
const res = await api('GET',
|
||||
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
|
||||
const msgs = res.messages;
|
||||
if (msgs.length) oldestCursor = msgs[0].id;
|
||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||
if (prepend) msgHost.prepend(...nodes);
|
||||
else {
|
||||
msgHost.append(...nodes);
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||
function ulidTime(id) {
|
||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
let ms = 0;
|
||||
for (const ch of String(id).slice(0, 10).toUpperCase()) ms = ms * 32 + A.indexOf(ch);
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
async function renderMessage(m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg' + (m.senderId === meId ? ' own' : '');
|
||||
const atts = (m.attachments || []).map((a) => a.isImage
|
||||
? `<img src="/files/${a.fileId}" alt="${esc(a.name)}" class="chat-img" data-lightbox loading="lazy">`
|
||||
: `<a class="btn small" href="/files/${a.fileId}">📄 ${esc(a.name)}</a>`).join(' ');
|
||||
div.innerHTML = `
|
||||
<p class="muted" style="margin:0">${esc(await userName(m.senderId))}
|
||||
· ${ulidTime(m.id).toLocaleString()}</p>
|
||||
<div>${m.body || ''}</div>
|
||||
${atts ? `<div class="mt">${atts}</div>` : ''}`;
|
||||
div.querySelectorAll('[data-lightbox]').forEach((img) => {
|
||||
img.addEventListener('click', () => {
|
||||
document.getElementById('lightbox-img').src = img.src;
|
||||
document.getElementById('lightbox').showModal();
|
||||
});
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
document.getElementById('lightbox').addEventListener('click', () => {
|
||||
document.getElementById('lightbox').close();
|
||||
});
|
||||
|
||||
// ---- composer ----
|
||||
document.querySelectorAll('[data-cmd]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
composer.focus();
|
||||
document.execCommand(btn.dataset.cmd, false, null);
|
||||
});
|
||||
});
|
||||
|
||||
composer.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
let typingTimer = null;
|
||||
composer.addEventListener('input', () => {
|
||||
if (!current) return;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => {
|
||||
wsSend('chat', 'typing', { conversationId: current.id });
|
||||
}, 250);
|
||||
});
|
||||
|
||||
async function uploadFiles(fileList) {
|
||||
for (const file of fileList) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await api('POST', '/api/v1/files', fd);
|
||||
pendingAttachments.push(res);
|
||||
renderPending();
|
||||
} catch (e) { toast(`Upload failed: ${e.message}`, 'err'); }
|
||||
}
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
const host = document.getElementById('chat-attachments');
|
||||
host.replaceChildren(...pendingAttachments.map((a, i) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge';
|
||||
chip.innerHTML = `${a.isImage ? '🖼' : '📄'} ${esc(a.name)} <button type="button" class="btn small ghost" aria-label="Remove attachment">×</button>`;
|
||||
chip.querySelector('button').addEventListener('click', () => {
|
||||
pendingAttachments.splice(i, 1);
|
||||
renderPending();
|
||||
});
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('chat-file').addEventListener('change', (e) => {
|
||||
uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
});
|
||||
composer.addEventListener('dragover', (e) => e.preventDefault());
|
||||
composer.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!current) return;
|
||||
const body = composer.innerHTML;
|
||||
if (!composer.textContent.trim() && !pendingAttachments.length) return;
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body, attachments: pendingAttachments.map((a) => a.fileId),
|
||||
});
|
||||
composer.innerHTML = '';
|
||||
pendingAttachments = [];
|
||||
renderPending();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- live events ----
|
||||
const typingNames = new Map();
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event === 'message' && data) {
|
||||
if (current && data.conversationId === current.id) {
|
||||
msgHost.appendChild(await renderMessage(data));
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else {
|
||||
loadConversations();
|
||||
}
|
||||
}
|
||||
if (event === 'typing' && data && current && data.conversationId === current.id) {
|
||||
const name = await userName(data.userId);
|
||||
typingNames.set(data.userId, Date.now());
|
||||
document.getElementById('typing-indicator').textContent = `${name} is typing…`;
|
||||
setTimeout(() => {
|
||||
if (Date.now() - (typingNames.get(data.userId) || 0) >= 2900) {
|
||||
document.getElementById('typing-indicator').textContent = '';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
onPollFallback(() => {
|
||||
loadConversations();
|
||||
if (current) {
|
||||
msgHost.replaceChildren();
|
||||
oldestCursor = '';
|
||||
loadMessages(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- new conversation dialog ----
|
||||
const dlg = document.getElementById('newconv-dialog');
|
||||
const selected = new Map();
|
||||
document.getElementById('conv-new').addEventListener('click', () => {
|
||||
selected.clear();
|
||||
renderSelected();
|
||||
dlg.showModal();
|
||||
});
|
||||
document.getElementById('nc-cancel').addEventListener('click', () => dlg.close());
|
||||
document.getElementById('nc-kind').addEventListener('change', (e) => {
|
||||
document.getElementById('nc-title-wrap').hidden = e.target.value === 'dm';
|
||||
document.getElementById('nc-customer-wrap').hidden = e.target.value !== 'project';
|
||||
});
|
||||
|
||||
let searchTimer = null;
|
||||
document.getElementById('nc-search').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(async () => {
|
||||
const q = document.getElementById('nc-search').value.trim();
|
||||
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||
const host = document.getElementById('nc-results');
|
||||
host.replaceChildren(...res.users.filter((u) => u.id !== meId).map((u) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn small';
|
||||
b.textContent = `${u.name} <${u.email}>`;
|
||||
b.addEventListener('click', () => { selected.set(u.id, u); renderSelected(); });
|
||||
return b;
|
||||
}));
|
||||
}, 250);
|
||||
});
|
||||
|
||||
function renderSelected() {
|
||||
const host = document.getElementById('nc-selected');
|
||||
host.replaceChildren(...[...selected.values()].map((u) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge';
|
||||
chip.textContent = u.name + ' ×';
|
||||
chip.style.cursor = 'pointer';
|
||||
chip.addEventListener('click', () => { selected.delete(u.id); renderSelected(); });
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('newconv-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const kind = document.getElementById('nc-kind').value;
|
||||
const res = await api('POST', '/api/v1/conversations', {
|
||||
kind,
|
||||
title: document.getElementById('nc-title').value.trim(),
|
||||
customerId: document.getElementById('nc-customer').value.trim(),
|
||||
participantIds: [...selected.keys()],
|
||||
});
|
||||
dlg.close();
|
||||
await loadConversations();
|
||||
const conv = conversations.find((c) => c.id === res.conversation.id);
|
||||
if (conv) openConversation(conv);
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
} catch (e) { /* page guard handles */ }
|
||||
loadConversations();
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
{{define "content"}}
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<div class="chat-layout">
|
||||
<aside class="chat-sidebar card">
|
||||
<div class="spread">
|
||||
<h2>Messages</h2>
|
||||
<button class="btn small primary" id="conv-new">+ New</button>
|
||||
</div>
|
||||
<ul id="conv-list" class="conv-list"></ul>
|
||||
</aside>
|
||||
<section class="chat-main card">
|
||||
<div class="spread">
|
||||
<h2 id="chat-title">Select a conversation</h2>
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||
<form id="chat-form" hidden>
|
||||
<div id="chat-attachments" class="chat-attachments"></div>
|
||||
<div class="chat-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button class="btn small" type="button" data-cmd="bold" aria-label="Bold"><b>B</b></button>
|
||||
<button class="btn small" type="button" data-cmd="italic" aria-label="Italic"><i>I</i></button>
|
||||
<button class="btn small" type="button" data-cmd="underline" aria-label="Underline"><u>U</u></button>
|
||||
<button class="btn small" type="button" data-cmd="strikeThrough" aria-label="Strikethrough"><s>S</s></button>
|
||||
<button class="btn small" type="button" data-cmd="insertUnorderedList" aria-label="Bulleted list">• list</button>
|
||||
<label class="btn small" style="cursor:pointer">📎<input type="file" id="chat-file" multiple hidden></label>
|
||||
</div>
|
||||
<div id="chat-composer" class="chat-composer" contenteditable="true" role="textbox"
|
||||
aria-multiline="true" aria-label="Message" data-placeholder="Write a message… (drag & drop files)"></div>
|
||||
<div class="spread mt">
|
||||
<span class="hint">Enter to send · Shift+Enter for newline</span>
|
||||
<button class="btn primary" type="submit">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog id="newconv-dialog">
|
||||
<form method="dialog" id="newconv-form" class="stack" style="min-width:420px">
|
||||
<h2>New conversation</h2>
|
||||
<div class="field">
|
||||
<label for="nc-kind">Kind</label>
|
||||
<select id="nc-kind">
|
||||
<option value="dm">Direct message</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="nc-title-wrap" hidden>
|
||||
<label for="nc-title">Title</label>
|
||||
<input type="text" id="nc-title">
|
||||
</div>
|
||||
<div class="field" id="nc-customer-wrap" hidden>
|
||||
<label for="nc-customer">Customer id</label>
|
||||
<input type="text" id="nc-customer" placeholder="paste customer id">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc-search">Participants</label>
|
||||
<input type="search" id="nc-search" placeholder="Search people…">
|
||||
<div id="nc-results" class="stack" style="max-height:160px;overflow:auto"></div>
|
||||
<div id="nc-selected" class="mt"></div>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<button class="btn" type="button" id="nc-cancel">Cancel</button>
|
||||
<button class="btn primary" type="submit">Start conversation</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="lightbox" class="lightbox">
|
||||
<img id="lightbox-img" alt="">
|
||||
</dialog>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user