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:
etalon
2026-06-12 20:15:56 +02:00
parent 70a813edfa
commit 7d3140334e
11 changed files with 1224 additions and 5 deletions
+72
View File
@@ -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}}