8 Commits

Author SHA1 Message Date
maaz519 c9cd0c847b Merge pull request 'Feat/messaging ui foundation' (#13) from feat/messaging-ui-foundation into dev
Reviewed-on: #13
2026-07-25 12:28:37 +00:00
maaz519 d2820a64e3 chore(kernel-client): 0.1.6
0.1.5 was published with npm, which does not rewrite pnpm's workspace: protocol,
so its package.json carried '@insignia/iios-contracts: workspace:*' and npm
consumers failed with EUNSUPPORTEDPROTOCOL. Republished via pnpm publish. Do not
use 0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 272c6acd31 fix(messaging): a sent message no longer shows "Seen" instantly
Two independent defects; either alone caused it, in DMs, groups and channels.

1. useMessages reported a read of the newest NON-PENDING message regardless of
   author. Sending therefore made the client immediately mark its own message
   read, the server echoed a receipt for it, and the sender's bubble showed
   "Seen" before anyone had opened the thread. You do not read your own message:
   lastReadableId now skips your own.

2. The guard meant to catch exactly this — `e.actorId !== currentActorId` — could
   never fire: the receipt carries the reader's IIOS actor UUID while clients hold
   a userId, so the comparison was always true and every receipt, including your
   own, counted as the other side. The gateway now also emits userId on READ and
   DELIVERED receipts (matching what `annotation` already did), and ReceiptEvent
   carries it as optional so older servers still typecheck.

MockAdapter.markRead was a no-op, so it could not exercise any of this; it now
echoes a receipt like a real server. Regression test verified to fail without
the fix. SDK 0.1.14; suite 17 files / 101 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 eb0ace8ad7 fix(messaging-ui): composer height was being clobbered by a class-name collision
The chat composer reused .miu-textarea, which the INBOX MAIL composer already
owned further down the stylesheet with `resize: vertical; min-height: 90px`.
Equal specificity, later rule wins — so the mail styling applied to the chat box:
90px tall with a resize grabber, and every height fix in 0.1.10–0.1.12 was
silently overridden. That is why the box never changed.

The composer now uses its own .miu-composer-box; the inbox rule is untouched.
Adds a regression test asserting the composer does not carry .miu-textarea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 e2f66fe622 fix(messaging-ui): composer input back to its original 38px height
0.1.11 left it at 40px with line-height 1.45, so the line (20.3px) overflowed
the 20px content box — taller than the <input> it replaced, and liable to show a
scrollbar on a single line.

Collapsed height is now one token, --miu-composer-h: 38px, shared by the
textarea, attach and send so they cannot drift apart again. Padding tightened to
8px (from .miu-input's 9px) with line-height 1.4, so a 14px line is 19.6 + 16 + 2
= 37.6px and fits exactly — matching the original single-line input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 9a0c74cb6e fix(messaging-ui): composer no longer stretches the send/attach buttons
The textarea swap made the composer row tall and dragged the controls with it.
Three causes, all fixed:

- No box-sizing anywhere in the stylesheet, so `min-height: 38px` on a padded,
  bordered textarea rendered ~58px (content-box adds 18px padding + 2px border
  on top). The textarea is now border-box with a 40px min-height — the same
  height the old single-line input had.
- .miu-composer-row is display:flex with no align-items, so it defaulted to
  `stretch` and the buttons — neither of which declared a height — grew to the
  row. Now align-items: flex-end, so controls stay pinned to the bottom while
  the box grows upward (WhatsApp behaviour), with an explicit 40px on both.
  The send height is scoped to .miu-composer so modal/settings buttons keep
  their own sizing.
- The auto-grow effect set height = scrollHeight, which under content-box
  double-counted padding on every keystroke. It now compensates for the border
  explicitly, correct under border-box.

Bumped to 0.1.11. SDK suite: 17 files / 98 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 0c8eaaf74b fix(messaging-ui): unreadable code chip on own messages + raw markers in previews
Two bugs from the formatting work, both visible in the CRM messenger:

1. Inline code / code blocks were invisible in your OWN bubbles. `.miu-code` sets
   background: --miu-panel-2 (#1d1d26) while the is-mine override set only the
   colour to --miu-accent-text (#1a1206) — near-black on near-black, ~1.03:1
   contrast. The chip now tints the accent bubble (rgba(0,0,0,.16)) instead of
   using the panel colour, so it reads against any accent.

2. The conversation list showed the raw last message, so a strikethrough message
   previewed as "~crazy~". Adds stripMarkup() — markers off, code unwrapped,
   nesting handled, honouring the same word-boundary rule so snake_case_name and
   "5 * 3" survive — and uses it for the preview line.

Bumped to 0.1.10. SDK suite: 17 files / 98 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:57:23 +05:30
maaz519 ca02da000f Merge pull request 'Feat/messaging ui foundation' (#12) from feat/messaging-ui-foundation into dev
Reviewed-on: #12
2026-07-25 11:14:00 +00:00
14 changed files with 147 additions and 21 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-kernel-client", "name": "@insignia/iios-kernel-client",
"version": "0.1.4", "version": "0.1.6",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
+4
View File
@@ -37,7 +37,11 @@ export interface OpenThreadResult {
export interface ReceiptEvent { export interface ReceiptEvent {
interactionId: string; interactionId: string;
/** The reader's IIOS actor UUID — an internal id, NOT comparable to a caller's userId. */
actorId: string; actorId: string;
/** The reader's userId — the same id space clients hold, so they can ignore their own receipt.
* Optional: absent from servers older than the change that added it. */
userId?: string;
kind: 'READ' | 'DELIVERED'; kind: 'READ' | 'DELIVERED';
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-messaging-ui", "name": "@insignia/iios-messaging-ui",
"version": "0.1.9", "version": "0.1.14",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
@@ -270,8 +270,9 @@ export class MockAdapter implements MessagingAdapter {
// No-op: nobody is typing back in a mock. // No-op: nobody is typing back in a mock.
} }
async markRead(): Promise<void> { /** Echo a receipt the way a real server does, so the "ignore my own read" path is exercised. */
// No-op: the mock has no second party to report a read. async markRead(threadId: string, messageId: string): Promise<void> {
this.emit(threadId, { kind: 'receipt', messageId, actorId: ME });
} }
isConnected(): boolean { isConnected(): boolean {
@@ -16,6 +16,14 @@ describe('<Composer /> formatting + native text services', () => {
expect(box.getAttribute('autocorrect')).toBe('on'); expect(box.getAttribute('autocorrect')).toBe('on');
}); });
it('does NOT reuse .miu-textarea — that class belongs to the inbox mail composer', () => {
// Regression: sharing it let the mail rule (resize: vertical; min-height: 90px), which is
// declared later in the stylesheet, win at equal specificity and inflate the chat composer.
const { box } = mount();
expect(box.classList.contains('miu-composer-box')).toBe(true);
expect(box.classList.contains('miu-textarea')).toBe(false);
});
it('Enter sends, Shift+Enter does not (it makes a newline)', async () => { it('Enter sends, Shift+Enter does not (it makes a newline)', async () => {
const { onSend, box } = mount(); const { onSend, box } = mount();
fireEvent.change(box, { target: { value: 'hello' } }); fireEvent.change(box, { target: { value: 'hello' } });
@@ -54,11 +54,14 @@ export function Composer({
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
// Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field. // Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field.
// The box is border-box, but scrollHeight excludes the border, so add it back or every measure
// lands a couple of pixels short and the textarea shows a scrollbar it doesn't need.
useEffect(() => { useEffect(() => {
const el = inputRef.current; const el = inputRef.current;
if (!el) return; if (!el) return;
el.style.height = 'auto'; el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`; const border = el.offsetHeight - el.clientHeight;
el.style.height = `${el.scrollHeight + border}px`;
}, [draft]); }, [draft]);
/** /**
@@ -200,7 +203,7 @@ export function Composer({
) : null} ) : null}
<textarea <textarea
ref={inputRef} ref={inputRef}
className="miu-input miu-textarea" className="miu-input miu-composer-box"
value={draft} value={draft}
placeholder={placeholder} placeholder={placeholder}
aria-label="Message" aria-label="Message"
@@ -1,4 +1,5 @@
import type { Conversation } from '../types'; import type { Conversation } from '../types';
import { stripMarkup } from '../rich-text';
/** Initials for the avatar chip — first letters of the first two words. */ /** Initials for the avatar chip — first letters of the first two words. */
function initials(title: string): string { function initials(title: string): string {
@@ -37,7 +38,7 @@ export function ConversationList({
</span> </span>
<span className="miu-convrow-main"> <span className="miu-convrow-main">
<span className="miu-convrow-title">{c.title}</span> <span className="miu-convrow-title">{c.title}</span>
{c.lastMessage ? <span className="miu-convrow-preview">{c.lastMessage}</span> : null} {c.lastMessage ? <span className="miu-convrow-preview">{stripMarkup(c.lastMessage)}</span> : null}
</span> </span>
{c.unread > 0 ? ( {c.unread > 0 ? (
<span className="miu-badge" aria-label={`${c.unread} unread`}> <span className="miu-badge" aria-label={`${c.unread} unread`}>
@@ -158,3 +158,39 @@ describe('useMessages', () => {
} }
}); });
}); });
describe('read receipts', () => {
// REGRESSION: the hook reported a read of the newest message regardless of author, so sending a
// message made the server echo a receipt for it and the sender's own bubble showed "Seen"
// immediately — in DMs, groups and channels alike, before anyone had opened it.
it('never reports a read of my own message', async () => {
const adapter = new MockAdapter();
const markRead = vi.spyOn(adapter, 'markRead');
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
markRead.mockClear();
await act(async () => {
await result.current.send('a message from me');
});
// The newest message is now mine — reading it would be reading myself.
const mine = result.current.messages.filter((m) => m.mine).map((m) => m.id);
for (const call of markRead.mock.calls) expect(mine).not.toContain(call[1]);
});
it('does not mark my message seen just because the receipt came back', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.send('hello there');
});
const mine = result.current.messages.find((m) => m.mine && m.text === 'hello there');
expect(mine).toBeDefined();
expect(result.current.seenIds.has(mine!.id)).toBe(false);
});
});
@@ -160,13 +160,19 @@ export function useMessages(threadId: string | null): MessagesState {
if (threadId) adapter.sendTyping(threadId); if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]); }, [adapter, threadId]);
// The newest acknowledged (non-pending) message id — what we report as read. // The newest acknowledged (non-pending) message id from SOMEONE ELSE — what we report as read.
//
// Skipping my own messages is load-bearing, not tidiness: reporting a read of the message I just
// sent makes the server broadcast a receipt for it, and the sender's own client then paints it
// "Seen" the instant it is delivered, before anyone has looked at it.
const lastReadableId = useMemo(() => { const lastReadableId = useMemo(() => {
for (let i = raw.length - 1; i >= 0; i--) { for (let i = raw.length - 1; i >= 0; i--) {
if (!raw[i]!.pending) return raw[i]!.id; const m = raw[i]!;
if (m.pending || isOwnMessage(m, currentActorId)) continue;
return m.id;
} }
return null; return null;
}, [raw]); }, [raw, currentActorId]);
// Report my read of the newest message (drives the other side's "seen" tick). // Report my read of the newest message (drives the other side's "seen" tick).
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it. // Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
+1 -1
View File
@@ -10,7 +10,7 @@ export { useChannels } from './hooks/use-channels';
export { useMembers } from './hooks/use-members'; export { useMembers } from './hooks/use-members';
export { isOwnMessage } from './types'; export { isOwnMessage } from './types';
// Message-body markup (bold/italic/strike/code/links + mentions) as a safe ReactNode tree. // Message-body markup (bold/italic/strike/code/links + mentions) as a safe ReactNode tree.
export { renderRichText, safeHref } from './rich-text'; export { renderRichText, safeHref, stripMarkup } from './rich-text';
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens). // Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
export { Messenger } from './components/messenger'; export { Messenger } from './components/messenger';
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { renderRichText, safeHref } from './rich-text'; import { renderRichText, safeHref, stripMarkup } from './rich-text';
function show(text: string, names: string[] = []) { function show(text: string, names: string[] = []) {
render(<div data-testid="out">{renderRichText(text, names)}</div>); render(<div data-testid="out">{renderRichText(text, names)}</div>);
@@ -75,3 +75,21 @@ describe('renderRichText', () => {
expect(show('just a normal message').textContent).toBe('just a normal message'); expect(show('just a normal message').textContent).toBe('just a normal message');
}); });
}); });
describe('stripMarkup (one-line previews)', () => {
it('drops the markers so a preview never shows ~crazy~', () => {
expect(stripMarkup('~crazy~')).toBe('crazy');
expect(stripMarkup('*bold* and _italic_')).toBe('bold and italic');
});
it('unwraps code and flattens a fenced block', () => {
expect(stripMarkup('run `npm ci` now')).toBe('run npm ci now');
expect(stripMarkup('```\nconst a = 1;\n```')).toBe('const a = 1;');
});
it('handles nesting and leaves ordinary text (and identifiers) alone', () => {
expect(stripMarkup('*bold with _italic_*')).toBe('bold with italic');
expect(stripMarkup('snake_case_name and 5 * 3')).toBe('snake_case_name and 5 * 3');
expect(stripMarkup('plain preview')).toBe('plain preview');
});
});
@@ -71,6 +71,25 @@ function firstSpan(text: string): { start: number; end: number; rule: Rule; inne
return best; return best;
} }
/**
* The same text with its formatting markers removed, for places that show a one-line plain-text
* preview (conversation list, notifications) where `~crazy~` must read as "crazy" — you cannot
* render nodes into those, so the markers have to come off rather than be styled.
*/
export function stripMarkup(text: string): string {
const noCode = text
.replace(/```([\s\S]*?)```/g, (_m, code: string) => code.trim())
.replace(/`([^`\n]+)`/g, '$1');
return stripSpans(noCode);
}
/** Recursively drop *bold* / _italic_ / ~strike~ markers, honouring the same word-boundary rule. */
function stripSpans(text: string): string {
const span = firstSpan(text);
if (!span) return text;
return text.slice(0, span.start) + stripSpans(span.inner) + stripSpans(text.slice(span.end));
}
/** Linkify + mention-highlight a run of text that carries no other markup. */ /** Linkify + mention-highlight a run of text that carries no other markup. */
function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] { function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
const out: ReactNode[] = []; const out: ReactNode[] = [];
+33 -7
View File
@@ -10,6 +10,8 @@
--miu-accent: #fda913; --miu-accent: #fda913;
--miu-accent-text: #1a1206; --miu-accent-text: #1a1206;
--miu-radius: 12px; --miu-radius: 12px;
/* Collapsed composer height — textarea, attach and send all use this so they never diverge. */
--miu-composer-h: 38px;
display: flex; display: flex;
height: 100%; height: 100%;
@@ -371,13 +373,17 @@
.miu-composer-row { .miu-composer-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
/* Pin the controls to the bottom so a growing textarea never stretches them (WhatsApp). */
align-items: flex-end;
} }
.miu-file-input { .miu-file-input {
display: none; display: none;
} }
.miu-attach-btn { .miu-attach-btn {
flex-shrink: 0; flex-shrink: 0;
width: 38px; box-sizing: border-box;
width: var(--miu-composer-h);
height: var(--miu-composer-h);
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--miu-border); border: 1px solid var(--miu-border);
background: var(--miu-panel); background: var(--miu-panel);
@@ -515,13 +521,22 @@ button.miu-attach-dl:hover {
.miu-input:focus { .miu-input:focus {
border-color: var(--miu-accent); border-color: var(--miu-accent);
} }
/* Chat box: one row by default, grows with content (JS sets height), then scrolls. */ /* Chat box: one row by default, grows with content (JS sets height), then scrolls.
.miu-textarea { border-box so min/max-height are the REAL height — under the default content-box the padding
and border are added on top, which made a "38px" box render ~58px and dragged the row with it. */
/* Composer box only. NOTE: .miu-textarea is already taken by the inbox mail composer further
down (resize: vertical; min-height: 90px) — sharing it made that rule win and blow this up. */
.miu-composer-box {
box-sizing: border-box;
resize: none; resize: none;
min-height: 38px; /* Collapsed height must equal the single-line <input> this replaced: 20px of line + 16px
padding + 2px border = 38px. Padding is tightened from .miu-input's 9px so a 14px/1.4 line
fits the content box exactly — at 9px it overflowed and forced a scrollbar. */
padding: 8px 12px;
line-height: 1.4;
min-height: var(--miu-composer-h);
max-height: 160px; max-height: 160px;
overflow-y: auto; overflow-y: auto;
line-height: 1.45;
font-family: inherit; font-family: inherit;
} }
.miu-format-bar { .miu-format-bar {
@@ -568,8 +583,14 @@ button.miu-attach-dl:hover {
color: var(--miu-accent); color: var(--miu-accent);
text-decoration: underline; text-decoration: underline;
} }
.miu-msg.is-mine .miu-bubble .miu-link, .miu-msg.is-mine .miu-bubble .miu-link {
.miu-msg.is-mine .miu-bubble .miu-code { color: var(--miu-accent-text);
}
/* On your own (accent-filled) bubble the panel background would sit dark-on-dark against the dark
accent text — tint the bubble instead so the chip reads on any accent colour. */
.miu-msg.is-mine .miu-bubble .miu-code,
.miu-msg.is-mine .miu-bubble .miu-code-block {
background: rgba(0, 0, 0, 0.16);
color: var(--miu-accent-text); color: var(--miu-accent-text);
} }
.miu-send { .miu-send {
@@ -581,6 +602,11 @@ button.miu-attach-dl:hover {
color: var(--miu-accent-text); color: var(--miu-accent-text);
background: var(--miu-accent); background: var(--miu-accent);
} }
.miu-composer .miu-send {
box-sizing: border-box;
flex-shrink: 0;
height: var(--miu-composer-h);
}
.miu-send:disabled { .miu-send:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -159,7 +159,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
const r = await this.messages.markRead(body.threadId, principal, body.interactionId); const r = await this.messages.markRead(body.threadId, principal, body.interactionId);
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'READ' }); // userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
// USERID, so without this they cannot tell their own receipt from someone else's.
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'READ' });
return { ok: true }; return { ok: true };
} }
@@ -167,7 +169,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId); const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId);
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'DELIVERED' }); // userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
// USERID, so without this they cannot tell their own receipt from someone else's.
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'DELIVERED' });
return { ok: true }; return { ok: true };
} }