Compare commits
7 Commits
ca02da000f
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c9cd0c847b | |||
| d2820a64e3 | |||
| 272c6acd31 | |||
| eb0ace8ad7 | |||
| e2f66fe622 | |||
| 9a0c74cb6e | |||
| 0c8eaaf74b |
@@ -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",
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user