From adb5a6bb7b6a00c4b1ed74a5148e41af0fdfc515 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 16 Jul 2026 16:52:29 +0530 Subject: [PATCH] feat(messenger): reactions, replies, typing, seen ticks + UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the IIOS kernel client's existing realtime affordances into the Messenger UI and fix three display issues. - Bubble contrast: incoming bubbles used --panel-2, which equals --bg in the dark theme (both #060608) → invisible. Use --panel + a border. - DM title: mapped all participants (incl. self → unknown-id fallback), producing "User d10888, Maaz Ahmed". Now shows the counterpart only. - Live sidebar preview: lastMessage/time update on any inbound socket message, reconciled with a debounced conversation-list refetch. - Typing indicator: throttled typing() send + auto-expiring "typing…" line. - Read receipts: markRead() on open/new message; "Sent"/"Seen" under the last outgoing message. Receipt event carries no threadId, so it is a global stream filtered to my own messages by actor id. - Reactions: emoji picker on hover, chips with counts, live via annotation. - Reply/quote: parentInteractionId round-trips; quoted parent renders above the reply and in a composer quote bar. Presence (online + last-seen) is intentionally not included — the kernel has no presence receive-event yet; that needs an IIOS change + client release. Co-Authored-By: Claude Opus 4.8 --- src/components/dashboard/messenger.tsx | 160 +++++++++++++++++---- src/lib/messenger-api.ts | 187 +++++++++++++++++++++---- src/lib/messenger-socket.tsx | 94 ++++++++++--- 3 files changed, 368 insertions(+), 73 deletions(-) diff --git a/src/components/dashboard/messenger.tsx b/src/components/dashboard/messenger.tsx index 6f6469f..5d84fe9 100644 --- a/src/components/dashboard/messenger.tsx +++ b/src/components/dashboard/messenger.tsx @@ -6,14 +6,14 @@ // thread view + composer, with a "new chat" people picker that // creates a DM (1 person) or group (2+). DM-vs-group and who-can- // chat are enforced server-side by IIOS/OPA; this is just UI. -// Data comes from useMessengerData()/useThread(): local mock when -// the Shell isn't configured, live be-crm when it is. +// Live messages, typing, read receipts and reactions come over the +// IIOS socket (Shell mode); mock keeps the demo working offline. // ============================================================ -import { type CSSProperties, useEffect, useRef, useState } from "react"; +import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui"; -import { useMessengerData, useThread, type Membership, type UiConversation, type UiPerson } from "@/lib/messenger-api"; -import { MessengerSocketProvider } from "@/lib/messenger-socket"; +import { useMessengerData, useThread, type Membership, type UiConversation, type UiMessage, type UiPerson } from "@/lib/messenger-api"; +import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket"; const initialsOf = (name: string) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; @@ -24,6 +24,7 @@ const timeOf = (iso?: string) => { }; const GROUP_GRAD = "linear-gradient(135deg,#6366f1,#8b5cf6)"; const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)"; +const REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; const inputStyle: CSSProperties = { width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", @@ -77,7 +78,7 @@ function MessengerPanel() {
{current ? ( - toast.push({ tone: "error", title: "Message failed", desc: msg })} /> + toast.push({ tone: "error", title: "Message failed", desc: msg })} /> ) : (
@@ -117,36 +118,55 @@ function ConversationRow({ c, active, onClick }: { c: UiConversation; active: bo
{c.title} - {c.membership === "group" && group} + {timeOf(c.lastAt)}
-
- {c.lastMessage ?? "No messages yet"} +
+ + {c.lastMessage ?? "No messages yet"} + + {c.unread > 0 && ( + {c.unread} + )}
- {c.unread > 0 && ( - {c.unread} - )} ); } -function ThreadView({ conv, onError }: { conv: UiConversation; onError: (m: string) => void }) { +function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (id: string) => string; onError: (m: string) => void }) { const t = useThread(conv.threadId); + const socket = useMessengerSocket(); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); + const [replyTo, setReplyTo] = useState(null); const endRef = useRef(null); + const typingSentAt = useRef(0); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]); + const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]); + const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]); + + function onDraftChange(v: string) { + setDraft(v); + const now = Date.now(); + if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; } + } + async function submit() { const text = draft.trim(); if (!text || sending) return; - setDraft(""); setSending(true); - try { await t.send(text); } + const parent = replyTo?.id; + setDraft(""); setReplyTo(null); setSending(true); + try { await t.send(text, parent ? { parentInteractionId: parent } : undefined); } catch (e) { setDraft(text); onError((e as Error).message); } finally { setSending(false); } } + const typingLabel = t.typingUserIds.length === 1 + ? `${nameOf(t.typingUserIds[0])} is typing…` + : t.typingUserIds.length > 1 ? "Several people are typing…" : ""; + return ( <>
@@ -159,28 +179,37 @@ function ThreadView({ conv, onError }: { conv: UiConversation; onError: (m: stri
-
+
{t.loading && t.messages.length === 0 &&
Loading messages…
} {!t.loading && t.messages.length === 0 &&
No messages yet — say hello 👋
} {t.messages.map((msg) => ( -
-
- {msg.text} -
-
{timeOf(msg.at)}
-
+ t.react(msg.id, emoji)} + onReply={() => setReplyTo(msg)} + /> ))}
+
{typingLabel}
+ + {replyTo && ( +
+
+
Replying to {replyTo.mine ? "yourself" : nameOf(replyTo.senderId ?? "")}
+
{replyTo.text}
+
+ +
+ )} +