eleven / ocho Public

App review fixes: security, cross-thread correctness, UI polish (#18)

* server: bind reactions/edits to their thread (close cross-thread IDOR)

react/unreact/edit gated on thread membership but acted on a client-supplied
message_id without checking the message lived in that thread. Since message ids
are sequential, a member of one thread could toggle reactions on — and read back
the counts of — messages in private threads they're not in. Add messageInThread()
and require it in the edit and react/unreact handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: composer hugs the bottom — padding-bottom = safe-area inset only

Drop the max(12px, …) floor so there's no fixed bottom padding on desktop
(env() is 0); on mobile the safe-area inset alone clears the Home indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: stop cross-thread bleed on fast thread switches

Guards checked activeId/msgEls before an await but not after, so switching
threads mid-decrypt could render a message (or apply an edit) into the wrong
thread's feed and corrupt its msgEls/msgData. Re-check after every decrypt:
- appendMessage bails if activeId changed during decrypt
- the WS edit handler re-validates activeId + msgEls after decrypt
- openThread gets a generation token (abandons a slow history load if you've
  switched away) and now clears per-thread composer state on switch
  (myReactions, staged attachment, half-typed text)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: rework message hover actions + restore burst grouping

- .msg now shrinks to the bubble width (align-self), so a 'them' message's hover
  icons sit right beside the bubble instead of way off at the 78% edge.
- Replace the three separately-positioned hover icons + floating timestamp with a
  single full-height .msg-actions strip beside the bubble: the hover zone spans the
  whole message height (move straight from anywhere in the bubble to an icon), and
  the icons + exact time sit in a row so they never overlap.
- Restore the .msg.cont tight grouping for consecutive same-sender messages, so
  quick-succession bursts aren't spaced like separate messages.
- Empty state: 'Tap +' → 'Tap the compose button' (the + is the attach button now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: restore the drag-and-drop 'Drop a photo to send' overlay

Drag-and-drop upload still worked but the visual cue was lost in the rewrite
(the .drop-overlay CSS was orphaned). Re-add the overlay element to the thread
view, give .thread-view position:relative so it can cover the pane, and toggle
it on dragover/dragleave/drop (relatedTarget check avoids child-flicker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: tap 'N members' in the group header to open the member list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 3ce466320b16977261639f4a56af68582f82883e parent e7c12e4
5 files changed, +91 −67
  • db.go +9 −0
  • hub.go +4 −2
  • web/pages/index.html +1 −0
  • web/static/chat.js +49 −15
  • web/static/style.css +28 −50
diff --git a/db.go b/db.go
index 175ebe8..477c7a1 100644
--- a/db.go
+++ b/db.go
@@ -681,6 +681,15 @@ func (db *DB) threadMember(threadID, userID string) bool {
return db.sql.QueryRow("SELECT 1 FROM thread_members WHERE thread_id = ? AND user_id = ?", threadID, userID).Scan(&x) == nil
}
+// messageInThread reports whether message id lives in threadID. Binds a
+// reaction/edit to the thread the client names (membership is checked
+// separately), so a member of one thread can't react to or edit messages in a
+// thread they don't belong to.
+func (db *DB) messageInThread(id int64, threadID string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM messages WHERE id = ? AND thread_id = ?", id, threadID).Scan(&x) == nil
+}
+
// wrappedKeyFor returns a member's wrapped thread key (to unwrap client-side).
func (db *DB) wrappedKeyFor(threadID, userID string) (string, bool, error) {
var w string
diff --git a/hub.go b/hub.go
index 2e9b1ee..158f7cc 100644
--- a/hub.go
+++ b/hub.go
@@ -248,7 +248,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
go s.pushNewMessage(m.Blob, in.ThreadID, token)
case "edit":
- if in.MessageID <= 0 || in.Blob == "" || len(in.Blob) > maxBlobBytes {
+ if in.MessageID <= 0 || in.Blob == "" || len(in.Blob) > maxBlobBytes ||
+ !s.db.messageInThread(in.MessageID, in.ThreadID) {
continue
}
m, ok, err := s.db.editMessage(in.MessageID, in.Blob, userID, editWindow)
@@ -263,7 +264,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
s.broadcastToThread(in.ThreadID, out)
case "react", "unreact":
- if in.MessageID <= 0 || !allowedReactions[in.Emoji] {
+ if in.MessageID <= 0 || !allowedReactions[in.Emoji] ||
+ !s.db.messageInThread(in.MessageID, in.ThreadID) {
continue
}
if in.Type == "react" {
diff --git a/web/pages/index.html b/web/pages/index.html
index e529029..c79284a 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -67,6 +67,7 @@
</div>
</header>
<div class="feed" id="feed"></div>
+ <div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
<div class="typing hidden" id="typing" aria-live="polite"></div>
<div class="composer-reply hidden" id="composer-reply"></div>
<div class="composer-edit hidden" id="composer-edit"></div>
diff --git a/web/static/chat.js b/web/static/chat.js
index b8bff23..3b1bc11 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -441,9 +441,19 @@ function renderApp() {
fileInput.value = "";
};
const pane = document.getElementById("thread-view");
- pane.addEventListener("dragover", (e) => e.preventDefault());
+ const dropOverlay = document.getElementById("drop-overlay");
+ pane.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ if (dropOverlay) dropOverlay.classList.remove("hidden");
+ });
+ pane.addEventListener("dragleave", (e) => {
+ // Hide only when the cursor truly leaves the pane — dragleave also fires when
+ // crossing onto a child element, so check relatedTarget.
+ if (dropOverlay && !pane.contains(e.relatedTarget)) dropOverlay.classList.add("hidden");
+ });
pane.addEventListener("drop", (e) => {
e.preventDefault();
+ if (dropOverlay) dropOverlay.classList.add("hidden");
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) addAttachment(f);
});
@@ -463,7 +473,7 @@ function renderThreadList() {
if (!threads.length) {
const empty = document.createElement("p");
empty.className = "thread-empty-note";
- empty.textContent = "No chats yet. Tap + to start one.";
+ empty.textContent = "No chats yet. Tap the compose button to start one.";
list.appendChild(empty);
return;
}
@@ -616,13 +626,15 @@ function refreshSeparator(el) {
// from one person is labelled once. Call after refreshSeparator (it reads whether
// a .time-sep now sits directly above).
function refreshSenderName(el) {
- const name = el.querySelector(":scope > .name");
- if (!name) return;
const prevSib = el.previousElementSibling;
const afterSeparator = !!prevSib && prevSib.classList.contains("time-sep");
const prev = prevMsgOf(el);
const sameSender = !!prev && prev.dataset.sender === el.dataset.sender;
- name.style.display = !afterSeparator && sameSender ? "none" : "";
+ // Continuation of a same-sender run (no separator between) → tighter top gap
+ // (.cont), so quick-succession messages aren't spaced like separate bursts.
+ el.classList.toggle("cont", sameSender && !afterSeparator);
+ const name = el.querySelector(":scope > .name");
+ if (name) name.style.display = !afterSeparator && sameSender ? "none" : "";
}
function showList() {
@@ -660,9 +672,12 @@ function refreshAvatars() {
if (activeId) paintHeaderAvatar(activeId);
}
+let openSeq = 0; // bumped each openThread so a slow history load bails if you switch away
+
async function openThread(id) {
stopTyping(); // stop beating into the thread we're leaving
resetTyping(); // clear any "X is typing…" carried over from another thread
+ const myOpen = ++openSeq;
activeId = id;
unread[id] = false;
document.getElementById("thread-empty").classList.add("hidden");
@@ -674,6 +689,9 @@ async function openThread(id) {
const sub = document.getElementById("thread-sub");
const n = t ? (t.members || []).length : 0;
sub.textContent = isGroup ? `${n} member${n === 1 ? "" : "s"}` : "";
+ // Tapping "N members" opens the member list (groups only).
+ sub.onclick = isGroup ? () => showMembers() : null;
+ sub.style.cursor = isGroup ? "pointer" : "";
document.getElementById("thread-menu-wrap").style.display = isGroup ? "" : "none";
paintHeaderAvatar(id);
document.getElementById("tt").classList.add("show-thread");
@@ -683,17 +701,25 @@ async function openThread(id) {
feed.innerHTML = "";
msgEls = {};
msgData = {};
+ myReactions.clear(); // per-thread highlight; repopulated from each message's `mine`
+ clearPending(); // don't carry a staged photo (encrypted to the old thread's key) over
cancelEdit();
cancelReply();
+ const composerText = document.getElementById("text");
+ if (composerText) {
+ composerText.value = ""; // drop any half-typed text from the previous thread
+ composerText.dispatchEvent(new Event("input"));
+ }
try {
const r = await (
await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
).json();
+ if (myOpen !== openSeq) return; // switched threads while loading — abandon this load
for (const m of r.messages || []) await appendMessage(id, m, false);
} catch (e) {
console.error("load messages", e);
}
- feed.scrollTop = feed.scrollHeight;
+ if (myOpen === openSeq) feed.scrollTop = feed.scrollHeight;
if (!isCoarse) document.getElementById("text").focus();
}
@@ -707,6 +733,7 @@ async function appendMessage(threadId, m, scroll = true) {
} catch {
return; // can't decrypt — skip
}
+ if (threadId !== activeId) return; // switched threads mid-decrypt — don't bleed into the new feed
const feed = document.getElementById("feed");
const mine = data.name === session.name;
const t = threads.find((x) => x.id === threadId);
@@ -761,21 +788,25 @@ async function appendMessage(threadId, m, scroll = true) {
if (isCoarse) {
attachSwipeReply(wrap, replyMeta);
} else {
- content.appendChild(buildHoverReact(m.id));
- content.appendChild(buildHoverReply(replyMeta));
+ // Desktop: a full-height strip beside the bubble with the action icons and the
+ // exact time, all revealed on hover (see .msg-actions). Full height so the
+ // pointer can travel from anywhere in the bubble straight to an icon.
+ const actions = document.createElement("div");
+ actions.className = "msg-actions";
+ actions.appendChild(buildHoverReact(m.id));
+ actions.appendChild(buildHoverReply(replyMeta));
const remaining = m.created_at * 1000 + EDIT_WINDOW_MS - Date.now();
if (mine && data.text && remaining > 0) {
const editBtn = buildHoverEdit(m.id);
- content.appendChild(editBtn);
+ actions.appendChild(editBtn);
setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
}
+ const timeEl = document.createElement("span");
+ timeEl.className = "msg-time";
+ timeEl.textContent = formatTime(data.ts || m.created_at * 1000);
+ actions.appendChild(timeEl);
+ content.appendChild(actions);
}
- // Exact time, revealed on hover (desktop); the day/time context otherwise lives
- // in the separators.
- const timeEl = document.createElement("div");
- timeEl.className = "msg-time";
- timeEl.textContent = formatTime(data.ts || m.created_at * 1000);
- content.appendChild(timeEl);
wrap.appendChild(content);
// Reactions: track which are mine (server's per-message flag), render the chips
// below the bubble (outside .content), and wire the press-hold / double-tap picker.
@@ -1361,6 +1392,9 @@ function connectWS() {
if (msg.thread_id === activeId && msgEls[msg.id]) {
try {
const nd = await decrypt(threadKeys[msg.thread_id], msg.blob);
+ // Re-validate after the await: a fast thread switch could have cleared
+ // msgEls or changed the active thread while we were decrypting.
+ if (msg.thread_id !== activeId || !msgEls[msg.id]) return;
const bubble = msgEls[msg.id].querySelector(".bubble");
if (bubble) {
bubble.textContent = "";
diff --git a/web/static/style.css b/web/static/style.css
index 3b7dc99..418dda8 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -223,9 +223,10 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.msg {
display: flex;
flex-direction: column;
+ align-self: flex-start; /* shrink to the bubble width (me overrides to flex-end) */
max-width: 78%;
margin-top: 10px;
- position: relative; /* anchors the swipe-to-reply icon */
+ position: relative; /* anchors hover affordances + the swipe-to-reply icon */
touch-action: pan-y; /* vertical scroll stays native; we handle horizontal swipes */
}
/* continuation of a same-sender, same-minute run — tighter gap (see refreshGrouping) */
@@ -282,35 +283,24 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* persistent "Edited" label under an edited bubble */
.edited-tag { font-size: 11px; color: var(--muted); margin: 1px 6px 0; }
-/* exact per-message time, revealed on hover (desktop) just beyond the hover
- reply/edit icons so they don't overlap */
-.msg-time {
+/* Hover affordances live in a full-height strip beside the bubble: the hover zone
+ spans the whole message height, so you can move straight from anywhere in the
+ bubble to an icon (no need to find the vertical centre), and the action icons +
+ the exact time sit in a row so they never overlap. Revealed on hover (desktop). */
+.msg-actions {
position: absolute;
- top: 50%;
- transform: translateY(-50%);
- font-size: 11px;
- color: var(--muted);
- white-space: nowrap;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
}
-.msg.them .msg-time { left: 100%; margin-left: 34px; }
-.msg.me .msg-time { right: 100%; margin-right: 62px; }
-@media (hover: hover) {
- .msg:hover .msg-time { opacity: 1; }
-}
-
-/* desktop hover react/reply/edit icons — absolutely positioned just outside the
- bubble so they reveal on hover without shifting any content. They abut the
- bubble (no gap) so the pointer can travel from bubble to icon without losing
- the hover. */
-.react-hover,
-.reply-hover,
-.edit-hover {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
+.msg.them .msg-actions { left: 100%; padding-left: 4px; } /* just right of the bubble */
+.msg.me .msg-actions { right: 100%; padding-right: 4px; flex-direction: row-reverse; } /* just left */
+.msg-actions button {
background: transparent;
color: var(--muted);
padding: 4px 6px;
@@ -318,27 +308,17 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
display: inline-flex;
align-items: center;
line-height: 1;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.12s;
}
-/* them: icons to the right of the bubble; me: to the left. React sits beside the
- reply icon; edit (your own in-window messages only) sits just beyond them. */
-.reply-hover { right: -30px; }
-.react-hover { right: -58px; }
-.msg.me .reply-hover { right: auto; left: -30px; }
-.msg.me .react-hover { right: auto; left: -58px; }
-.msg.me .edit-hover { right: auto; left: -86px; }
-.react-hover svg,
-.reply-hover svg,
-.edit-hover svg { width: 18px; height: 18px; }
-.react-hover:hover,
-.reply-hover:hover,
-.edit-hover:hover { background: var(--bubble-them); color: var(--accent); }
+.msg-actions button:hover { background: var(--bubble-them); color: var(--accent); }
+.msg-actions button svg { width: 18px; height: 18px; }
+.msg-actions .msg-time {
+ font-size: 11px;
+ color: var(--muted);
+ white-space: nowrap;
+ margin: 0 4px;
+}
@media (hover: hover) {
- .msg:hover .react-hover,
- .msg:hover .reply-hover,
- .msg:hover .edit-hover { opacity: 1; pointer-events: auto; }
+ .msg:hover .msg-actions { opacity: 1; pointer-events: auto; }
}
/* message content (text bubble + any attachments) */
@@ -671,12 +651,10 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
display: flex;
gap: 8px;
align-items: flex-end;
- /* Bottom inset is dynamic, not fixed+inset: on desktop env() is 0 so it falls
- back to 12px (matching the 12px top — symmetric); on mobile it becomes the
- safe-area inset itself, clearing the Home indicator / Safari swipe bar
- without stacking an extra 12px on top of it. */
+ /* The input hugs the bottom: no fixed bottom padding (env() is 0 on desktop),
+ just the safe-area inset on mobile to clear the Home indicator / swipe bar. */
padding: 12px 16px;
- padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
+ padding-bottom: env(safe-area-inset-bottom, 0px);
border-top: 1px solid var(--border);
background: var(--panel);
}
@@ -906,7 +884,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.empty-art { font-size: 42px; }
/* min-width:0 lets these panes shrink below their content so a long unbroken
URL wraps inside a bubble instead of widening the whole column. */
-.thread-view { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; }
+.thread-view { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; position: relative; }
.thread-head { display: flex; align-items: center; gap: 6px; }
.thread-head-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.thread-title { font-weight: 700; font-size: 16px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }