diff --git a/.env.example b/.env.example index fa35fec..5c70a36 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ JWT_SECRET="dev-only-change-me" # Per-app HS256 secrets, JSON map keyed by appId (the `session` platform port) APP_SECRETS={"portal-demo":"dev-secret"} +# Redis. Unset → single-instance mode: socket.io uses its in-memory adapter (realtime does NOT +# fan out across replicas) and presence is a per-process Map (so the "don't push a thread you're +# viewing" gate only sees sockets on the same replica). REQUIRED for any multi-replica deploy. +REDIS_URL="redis://localhost:6379" + # Full-text message search (Meilisearch). Unset MEILI_URL → search is disabled (no-op). # MEILI_KEY must equal the meilisearch server's MEILI_MASTER_KEY (docker-compose default below). MEILI_URL="http://localhost:7700" diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 0b7dcf3..78d31d1 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-messaging-ui", - "version": "0.1.7", + "version": "0.1.9", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 0f7ca27..0502c0b 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -1,5 +1,6 @@ import type { Attachment, + BulkAddResult, ChannelSummary, Conversation, CreateChannelInput, @@ -97,6 +98,11 @@ export interface MessagingAdapter { /** Add a person to a group or private channel. */ addMember?(threadId: string, userId: string): Promise; + /** Add many people in one call — powers "add everyone from another channel". The host is expected + * to enforce who may add (and who may read the source roster) server-side. + * Absent => the import-from-a-channel affordance is hidden; single add still works. */ + addMembers?(threadId: string, userIds: string[]): Promise; + /** Remove a person from a group or channel. */ removeMember?(threadId: string, userId: string): Promise; diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index b114d7c..5514657 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -1,6 +1,7 @@ import type { MessagingAdapter } from '../adapter'; import type { Attachment, + BulkAddResult, ChannelSummary, ChannelVisibility, Conversation, @@ -168,6 +169,22 @@ export class MockAdapter implements MessagingAdapter { if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId]; } + /** Bulk add, mirroring the live door: already-members come back as `skipped`, never re-added. */ + async addMembers(threadId: string, userIds: string[]): Promise { + const t = this.threads.get(threadId); + if (!t) return { added: [], skipped: [], failed: [...userIds] }; + const added: string[] = []; + const skipped: string[] = []; + for (const id of [...new Set(userIds)]) { + if (t.participants.includes(id)) skipped.push(id); + else { + t.participants = [...t.participants, id]; + added.push(id); + } + } + return { added, skipped, failed: [] }; + } + async removeMember(threadId: string, userId: string): Promise { const t = this.threads.get(threadId); if (t) t.participants = t.participants.filter((p) => p !== userId); diff --git a/packages/iios-messaging-ui/src/components/composer.test.tsx b/packages/iios-messaging-ui/src/components/composer.test.tsx new file mode 100644 index 0000000..c6947f8 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/composer.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Composer } from './composer'; + +function mount(onSend = vi.fn().mockResolvedValue(undefined)) { + render(); + return { onSend, box: screen.getByLabelText('Message') as HTMLTextAreaElement }; +} + +describe(' formatting + native text services', () => { + it('is a textarea with the platform spellchecker enabled', () => { + const { box } = mount(); + expect(box.tagName).toBe('TEXTAREA'); + // The red squiggle + right-click suggestions come from the OS via these attributes. + expect(box.getAttribute('spellcheck')).toBe('true'); + expect(box.getAttribute('autocorrect')).toBe('on'); + }); + + it('Enter sends, Shift+Enter does not (it makes a newline)', async () => { + const { onSend, box } = mount(); + fireEvent.change(box, { target: { value: 'hello' } }); + + fireEvent.keyDown(box, { key: 'Enter', shiftKey: true }); + expect(onSend).not.toHaveBeenCalled(); + + fireEvent.keyDown(box, { key: 'Enter' }); + await waitFor(() => expect(onSend).toHaveBeenCalledWith('hello', expect.anything())); + }); + + it('does not send mid-IME composition', () => { + const { onSend, box } = mount(); + fireEvent.change(box, { target: { value: 'にほん' } }); + fireEvent.keyDown(box, { key: 'Enter', isComposing: true }); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('a toolbar button wraps the current selection in its marker', () => { + const { box } = mount(); + fireEvent.change(box, { target: { value: 'make me bold' } }); + box.setSelectionRange(8, 12); // "bold" + fireEvent.click(screen.getByLabelText('Bold (⌘B)')); + expect(box.value).toBe('make me *bold*'); + }); + + it('⌘B / ⌘I wrap the selection too', () => { + const { box } = mount(); + fireEvent.change(box, { target: { value: 'hello world' } }); + box.setSelectionRange(0, 5); + fireEvent.keyDown(box, { key: 'b', metaKey: true }); + expect(box.value).toBe('*hello* world'); + + box.setSelectionRange(8, 13); // "world" shifted by the two markers + fireEvent.keyDown(box, { key: 'i', ctrlKey: true }); + expect(box.value).toBe('*hello* _world_'); + }); + + it('with nothing selected, a marker pair is inserted at the caret', () => { + const { box } = mount(); + fireEvent.change(box, { target: { value: 'ab' } }); + box.setSelectionRange(2, 2); + fireEvent.click(screen.getByLabelText('Italic (⌘I)')); + expect(box.value).toBe('ab__'); + }); + + it('sends the raw markers as plain text — formatting is a render concern', async () => { + const { onSend, box } = mount(); + fireEvent.change(box, { target: { value: 'ship *today*' } }); + fireEvent.keyDown(box, { key: 'Enter' }); + await waitFor(() => expect(onSend).toHaveBeenCalledWith('ship *today*', expect.anything())); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/composer.tsx b/packages/iios-messaging-ui/src/components/composer.tsx index 0437cfa..20ecc33 100644 --- a/packages/iios-messaging-ui/src/components/composer.tsx +++ b/packages/iios-messaging-ui/src/components/composer.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'; import { SPECIAL_MENTIONS, insertMention, @@ -13,6 +13,17 @@ interface Suggestion { insert: string; } +/** Keyboard shortcut → the marker it wraps the selection in. */ +const SHORTCUTS: Record = { b: '*', i: '_', e: '`' }; + +/** Toolbar affordances for the same markers (strikethrough is button-only — no common shortcut). */ +const FORMAT_BUTTONS: Array<{ marker: string; label: string; title: string }> = [ + { marker: '*', label: 'B', title: 'Bold (⌘B)' }, + { marker: '_', label: 'I', title: 'Italic (⌘I)' }, + { marker: '~', label: 'S', title: 'Strikethrough' }, + { marker: '`', label: '‹›', title: 'Code (⌘E)' }, +]; + /** * The message input: draft, @mention autocomplete, and attachment staging. Shared by the main * Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread). @@ -24,7 +35,7 @@ export function Composer({ onSend, onTyping, parentInteractionId, - placeholder = 'Type a message… @ to mention', + placeholder = 'Type a message… @ to mention, *bold*', }: { members: Person[]; canUpload: boolean; @@ -40,6 +51,36 @@ export function Composer({ const [uploading, setUploading] = useState(false); const [uploadingName, setUploadingName] = useState(null); const fileRef = useRef(null); + const inputRef = useRef(null); + + // Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field. + useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, [draft]); + + /** + * Wrap the current selection in a marker (or, with nothing selected, drop in an empty pair and + * park the caret inside). Text stays plain — the marker characters ARE the format, so this never + * needs a rich-text model and the native spellchecker keeps working on the raw string. + */ + function wrapSelection(marker: string): void { + const el = inputRef.current; + if (!el) return; + const start = el.selectionStart ?? draft.length; + const end = el.selectionEnd ?? start; + const selected = draft.slice(start, end); + const next = `${draft.slice(0, start)}${marker}${selected}${marker}${draft.slice(end)}`; + setDraft(next); + // Restore a sensible selection after React re-renders the value. + const caret = selected ? start + marker.length + selected.length + marker.length : start + marker.length; + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(selected ? start + marker.length : caret, selected ? caret - marker.length : caret); + }); + } const query = trailingMentionQuery(draft); const suggestions = useMemo(() => { @@ -93,10 +134,26 @@ export function Composer({ } } - function onKeyDown(e: KeyboardEvent): void { - if (showSuggest && e.key === 'Enter') { + function onKeyDown(e: KeyboardEvent): void { + // Formatting shortcuts, matching the toolbar. Cmd on macOS, Ctrl elsewhere. + if (e.metaKey || e.ctrlKey) { + const marker = SHORTCUTS[e.key.toLowerCase()]; + if (marker) { + e.preventDefault(); + wrapSelection(marker); + return; + } + } + if (e.key !== 'Enter') return; + if (showSuggest) { e.preventDefault(); pick(suggestions[0]!.insert); + return; + } + // Enter sends; Shift+Enter (and IME composition) inserts a newline. + if (!e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + void submit(); } } @@ -125,6 +182,13 @@ export function Composer({ ) : null} +
+ {FORMAT_BUTTONS.map((b) => ( + + ))} +
{canUpload ? ( <> @@ -134,11 +198,18 @@ export function Composer({ ) : null} - { setDraft(e.target.value); onTyping?.(); diff --git a/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx b/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx index 3fa1c01..da45e78 100644 --- a/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx +++ b/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx @@ -37,4 +37,61 @@ describe('', () => { expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true); }); }); + + it('“#” switches the picker to channels you belong to, excluding this one', async () => { + mount(); + await screen.findByText('Dan Whitaker'); + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + // Channels the mock user is in show up as import sources… + await screen.findByText('#general'); + // …and the conversation being edited is never offered as its own source. + expect(screen.queryByText('#Storm crew')).toBeNull(); + }); + + it('imports a channel roster only after confirmation, and reports what landed', async () => { + const a = mount(); + await screen.findByText('Dan Whitaker'); + const before = (await a.listMembers('th_mock_2')).length; + + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + fireEvent.click(await screen.findByText('#general')); + + // Staged, NOT applied — picking a channel must not mutate membership on its own. + const confirm = await screen.findByRole('button', { name: /^Add \d+$/ }); + expect((await a.listMembers('th_mock_2')).length).toBe(before); + + fireEvent.click(confirm); + await waitFor(async () => { + expect((await a.listMembers('th_mock_2')).length).toBeGreaterThan(before); + }); + await screen.findByText(/Added \d+/); + }); + + it('cancelling a staged import leaves membership untouched', async () => { + const a = mount(); + await screen.findByText('Dan Whitaker'); + const before = (await a.listMembers('th_mock_2')).length; + + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + fireEvent.click(await screen.findByText('#general')); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + + await screen.findByLabelText('Search people'); // back to the picker + expect((await a.listMembers('th_mock_2')).length).toBe(before); + }); + + it('hides the channel-import affordance when the adapter cannot bulk-add', async () => { + const a = new MockAdapter(); + // A host that implements single add but not addMembers => no import path offered. + (a as { addMembers?: unknown }).addMembers = undefined; + render( + + {}} /> + , + ); + await screen.findByText('Dan Whitaker'); + expect(screen.getByLabelText('Search people').getAttribute('placeholder')).toBe('Search people…'); + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + expect(screen.queryByText('#general')).toBeNull(); // '#' is just a search string here + }); }); diff --git a/packages/iios-messaging-ui/src/components/conversation-settings.tsx b/packages/iios-messaging-ui/src/components/conversation-settings.tsx index 2f627b7..66a2e94 100644 --- a/packages/iios-messaging-ui/src/components/conversation-settings.tsx +++ b/packages/iios-messaging-ui/src/components/conversation-settings.tsx @@ -1,7 +1,17 @@ import { useEffect, useMemo, useState } from 'react'; import { useAdapter } from '../provider'; +import { useConversations } from '../hooks/use-conversations'; import { ModalPortal } from './modal-portal'; -import type { Person } from '../types'; +import type { BulkAddResult, Conversation, Person } from '../types'; + +/** A source channel the caller belongs to, staged for a roster import once its members are read. */ +interface PendingImport { + source: Conversation; + /** Members of the source who are NOT already here — the ones an import would actually add. */ + newcomers: Person[]; + /** Members of the source already in this conversation; reported so the count is never surprising. */ + alreadyHere: number; +} /** * Settings for a group or channel (public + private): rename, member list, add/remove people, and @@ -30,9 +40,23 @@ export function ConversationSettings({ const [error, setError] = useState(null); const [nonce, setNonce] = useState(0); + const [pending, setPending] = useState(null); + const [imported, setImported] = useState(null); + const { conversations } = useConversations(); + const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function'; const canRename = typeof adapter.renameConversation === 'function'; const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function'; + // Importing a roster needs both halves: read the source's members, and bulk-add them here. + const canImport = typeof adapter.addMembers === 'function' && typeof adapter.listMembers === 'function'; + + // The channels you belong to are the only valid import sources — this list is already + // membership-scoped (it is your own conversation list), so private channels appear iff you're in + // them, and the server re-checks the source-membership rule on the roster read regardless. + const sourceChannels = useMemo( + () => conversations.filter((c) => c.membership === 'channel' && c.threadId !== threadId), + [conversations, threadId], + ); useEffect(() => { let alive = true; @@ -51,7 +75,41 @@ export function ConversationSettings({ }, [adapter, threadId, nonce]); const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]); + // A leading '#' switches the picker from people to channels — the roster-import affordance. + const channelMode = canImport && q.trim().startsWith('#'); + const channelQuery = q.trim().slice(1).toLowerCase(); const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase())); + const matchingChannels = sourceChannels.filter((c) => c.title.toLowerCase().includes(channelQuery)); + + /** Stage an import: read the source roster, then diff it against who is already here. */ + async function stageImport(source: Conversation): Promise { + setBusy(true); + setError(null); + try { + const roster = await adapter.listMembers!(source.threadId); + setPending({ + source, + newcomers: roster.filter((p) => !memberIds.has(p.id)), + alreadyHere: roster.filter((p) => memberIds.has(p.id)).length, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + /** Commit the staged import. One bulk call; the result reports what actually landed. */ + async function confirmImport(): Promise { + if (!pending) return; + const ids = pending.newcomers.map((p) => p.id); + await run(async () => { + const res = await adapter.addMembers!(threadId, ids); + setImported(res); + setPending(null); + setQ(''); + }); + } async function run(fn: () => Promise): Promise { setBusy(true); @@ -109,23 +167,73 @@ export function ConversationSettings({
- {canManage ? ( + {canManage && pending ? ( + // Confirm step — an import is an administrative action, so it never fires on a stray click. +
+ Add from #{pending.source.title} + {pending.newcomers.length === 0 ? ( +
Everyone from #{pending.source.title} is already here.
+ ) : ( +
+ Add {pending.newcomers.length} {pending.newcomers.length === 1 ? 'person' : 'people'} from #{pending.source.title}? + {pending.alreadyHere > 0 ? ` (${pending.alreadyHere} already here)` : ''} +
+ )} +
+ + +
+
+ ) : null} + + {canManage && !pending ? (
Add people - setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" /> + { setQ(e.target.value); setImported(null); }} + placeholder={canImport ? 'Search people… or # for a channel' : 'Search people…'} + aria-label="Search people" + />
- {addable.length === 0 ?
No one to add.
: null} - {addable.slice(0, 25).map((p) => ( - - ))} + {channelMode ? ( + <> + {matchingChannels.length === 0 ?
No channels to add from.
: null} + {matchingChannels.slice(0, 25).map((c) => ( + + ))} + + ) : ( + <> + {addable.length === 0 ?
No one to add.
: null} + {addable.slice(0, 25).map((p) => ( + + ))} + + )}
) : null} + {imported ? ( +
+ Added {imported.added.length} + {imported.skipped.length > 0 ? ` · ${imported.skipped.length} already a member` : ''} + {imported.failed.length > 0 ? ` · ${imported.failed.length} failed` : ''} +
+ ) : null} + {error ?
{error}
: null}
diff --git a/packages/iios-messaging-ui/src/components/message-item.tsx b/packages/iios-messaging-ui/src/components/message-item.tsx index 6b5e243..e151a14 100644 --- a/packages/iios-messaging-ui/src/components/message-item.tsx +++ b/packages/iios-messaging-ui/src/components/message-item.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import { highlightMentions } from '../mentions'; +import { renderRichText } from '../rich-text'; import { PopoverPortal } from './popover-portal'; import type { Attachment } from '../types'; import type { UiMessage } from '../hooks/use-messages'; @@ -65,7 +65,7 @@ export function MessageItem({
- {m.text ? highlightMentions(m.text, memberNames) : null} + {m.text ? renderRichText(m.text, memberNames) : null} {m.attachment ? : null}