feat(messaging-ui): message timestamps + group/channel settings panel (0.1.5) #9

Merged
maaz519 merged 1 commits from feat/messaging-ui-foundation into dev 2026-07-23 07:33:57 +00:00
9 changed files with 350 additions and 3 deletions
Showing only changes of commit 416cf59dc2 - Show all commits
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@insignia/iios-messaging-ui",
"version": "0.1.4",
"version": "0.1.5",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
+13
View File
@@ -80,4 +80,17 @@ export interface MessagingAdapter {
/** Leave a channel by id. */
leaveChannel?(threadId: string): Promise<void>;
// ── Group / channel administration (optional) ───────────────────
// Enable the settings panel for groups + channels. Absent methods hide their affordance;
// the server (OPA) still enforces who may actually add/remove/rename (admin-only).
/** Add a person to a group or private channel. */
addMember?(threadId: string, userId: string): Promise<void>;
/** Remove a person from a group or channel. */
removeMember?(threadId: string, userId: string): Promise<void>;
/** Rename a group or channel. */
renameConversation?(threadId: string, subject: string): Promise<void>;
}
@@ -163,6 +163,21 @@ export class MockAdapter implements MessagingAdapter {
return MOCK_PEOPLE.map((p) => ({ ...p }));
}
async addMember(threadId: string, userId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId];
}
async removeMember(threadId: string, userId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.participants = t.participants.filter((p) => p !== userId);
}
async renameConversation(threadId: string, subject: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.subject = subject;
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { ConversationSettings } from './conversation-settings';
function mount(adapter = new MockAdapter()) {
render(
<MessagingProvider adapter={adapter}>
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
</MessagingProvider>,
);
return adapter;
}
describe('MockAdapter member management', () => {
it('adds and removes a member, and renames', async () => {
const a = new MockAdapter();
await a.addMember('th_mock_2', 'pp_sofia');
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
await a.removeMember('th_mock_2', 'pp_sofia');
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(false);
await a.renameConversation('th_mock_2', 'Renamed');
expect((await a.listConversations()).find((c) => c.threadId === 'th_mock_2')?.title).toBe('Renamed');
});
});
describe('<ConversationSettings />', () => {
it('lists members and adds one from the directory', async () => {
const a = mount();
// A current member is shown.
await screen.findByText('Dan Whitaker');
// Add an addable person from the directory.
const sofia = await screen.findByText('Sofia Ramirez');
fireEvent.click(sofia);
await waitFor(async () => {
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
});
});
});
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react';
import { useAdapter } from '../provider';
import { ModalPortal } from './modal-portal';
import type { Person } from '../types';
/**
* Settings for a group or channel (public + private): rename, member list, add/remove people, and
* leave. Add/remove/rename appear only when the adapter implements them AND — for the server — OPA
* allows it (admin-only); the panel is optimistic and surfaces the error if the door refuses.
*/
export function ConversationSettings({
threadId,
title,
membership,
onClose,
onLeft,
}: {
threadId: string;
title: string;
membership: 'group' | 'channel';
onClose: () => void;
onLeft?: () => void;
}) {
const adapter = useAdapter();
const [members, setMembers] = useState<Person[]>([]);
const [directory, setDirectory] = useState<Person[]>([]);
const [name, setName] = useState(title);
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function';
const canRename = typeof adapter.renameConversation === 'function';
const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function';
useEffect(() => {
let alive = true;
void Promise.all([
adapter.listMembers ? adapter.listMembers(threadId) : Promise.resolve<Person[]>([]),
adapter.directory ? adapter.directory() : Promise.resolve<Person[]>([]),
]).then(([m, d]) => {
if (alive) {
setMembers(m);
setDirectory(d);
}
});
return () => {
alive = false;
};
}, [adapter, threadId, nonce]);
const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]);
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
async function run(fn: () => Promise<void>): Promise<void> {
setBusy(true);
setError(null);
try {
await fn();
setNonce((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<ModalPortal>
<div className="miu-modal-overlay" onMouseDown={onClose}>
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<div className="miu-modal-head">
<span>{membership === 'channel' ? 'Channel' : 'Group'} settings</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="miu-modal-body">
{canRename ? (
<div className="miu-field">
<span className="miu-field-lbl">Name</span>
<div className="miu-composer-row">
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} aria-label="Conversation name" />
<button
type="button"
className="miu-send"
disabled={busy || !name.trim() || name.trim() === title}
onClick={() => void run(() => adapter.renameConversation!(threadId, name.trim()))}
>
Rename
</button>
</div>
</div>
) : null}
<div className="miu-field">
<span className="miu-field-lbl">Members · {members.length}</span>
<div className="miu-settings-list">
{members.map((m) => (
<div key={m.id} className="miu-settings-member">
<span className="miu-settings-name">{m.name}</span>
<span className="miu-pill">{m.kind}</span>
{canManage ? (
<button type="button" className="miu-attach-x" title="Remove" disabled={busy} onClick={() => void run(() => adapter.removeMember!(threadId, m.id))}>
</button>
) : null}
</div>
))}
</div>
</div>
{canManage ? (
<div className="miu-field">
<span className="miu-field-lbl">Add people</span>
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
<div className="miu-settings-list">
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null}
{addable.slice(0, 25).map((p) => (
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}>
<span className="miu-settings-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
<span className="miu-settings-plus" aria-hidden="true"></span>
</button>
))}
</div>
</div>
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
</div>
<div className="miu-modal-foot">
{canLeave ? (
<button type="button" className="miu-tab" disabled={busy} onClick={() => void run(async () => { await adapter.leaveChannel!(threadId); onLeft?.(); onClose(); })}>
Leave {membership}
</button>
) : (
<span />
)}
<button type="button" className="miu-send" onClick={onClose}>Done</button>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -8,6 +8,20 @@ const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
const isImage = (mime: string): boolean => mime.startsWith('image/');
/** Slack-style message time: today shows the clock, then "Yesterday", weekday, else a date. */
function messageTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(+d)) return '';
const now = new Date();
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (d.toDateString() === now.toDateString()) return time;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return `Yesterday ${time}`;
if (now.getTime() - d.getTime() < 7 * 86400000) return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
}
function AttachmentView({ att }: { att: Attachment }) {
if (isImage(att.mime)) {
return (
@@ -54,6 +68,9 @@ export function MessageItem({
{m.text ? highlightMentions(m.text, memberNames) : null}
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
</div>
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
{messageTime(m.at)}
</time>
<div className="miu-msg-actions">
{canReact ? (
<div className="miu-react-wrap">
@@ -34,6 +34,7 @@ export function Messenger() {
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
function pick(threadId: string): void {
setBrowsing(false);
@@ -104,7 +105,16 @@ export function Messenger() {
}}
/>
) : (
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
<Thread
threadId={selected}
conversation={selectedConversation}
activeRootId={activeRoot}
onOpenThread={setActiveRoot}
onLeft={() => {
refetch();
setSelected(null);
}}
/>
)}
</section>
@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import { Composer } from './composer';
import { MessageItem } from './message-item';
import { ConversationSettings } from './conversation-settings';
import type { Conversation } from '../types';
/**
* The main conversation view: top-level messages + composer. Replies (messages with a
@@ -11,16 +13,24 @@ import { MessageItem } from './message-item';
*/
export function Thread({
threadId,
conversation,
activeRootId,
onOpenThread,
onLeft,
}: {
threadId: string | null;
conversation?: Conversation | null;
activeRootId?: string | null;
onOpenThread?: (rootId: string) => void;
onLeft?: () => void;
}) {
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const [settingsOpen, setSettingsOpen] = useState(false);
const membership = conversation?.membership;
const manageable = membership === 'group' || membership === 'channel';
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
const replyCount = useMemo(() => {
@@ -35,6 +45,19 @@ export function Thread({
return (
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
{conversation ? (
<div className="miu-thread-head">
<span className="miu-thread-title">
{membership === 'channel' ? '# ' : ''}
{conversation.title || 'Conversation'}
</span>
{manageable ? (
<button type="button" className="miu-thread-settings" title="Settings" aria-label="Conversation settings" onClick={() => setSettingsOpen(true)}>
</button>
) : null}
</div>
) : null}
<div className="miu-messages">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
@@ -56,6 +79,16 @@ export function Thread({
</div>
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
{settingsOpen && manageable && conversation ? (
<ConversationSettings
threadId={conversation.threadId}
title={conversation.title || ''}
membership={membership as 'group' | 'channel'}
onClose={() => setSettingsOpen(false)}
{...(onLeft ? { onLeft } : {})}
/>
) : null}
</div>
);
}
+74
View File
@@ -162,6 +162,80 @@
gap: 2px;
flex-shrink: 0;
}
.miu-msg-time {
font-size: 11px;
color: var(--miu-muted);
flex-shrink: 0;
white-space: nowrap;
}
/* thread header + group/channel settings */
.miu-thread-head {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--miu-border);
}
.miu-thread-title {
font-weight: 700;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-thread-settings {
flex-shrink: 0;
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 4px;
border-radius: 8px;
}
.miu-thread-settings:hover {
background: var(--miu-panel-2);
color: var(--miu-text);
}
.miu-settings-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.miu-settings-member {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px solid var(--miu-border);
border-radius: 10px;
background: var(--miu-panel);
color: var(--miu-text);
font-size: 13px;
text-align: left;
}
.miu-settings-member.is-add {
cursor: pointer;
}
.miu-settings-member.is-add:hover {
border-color: var(--miu-accent);
}
.miu-settings-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-settings-plus {
color: var(--miu-accent);
font-weight: 700;
}
.miu-thread-link {
align-self: flex-start;
margin-top: 3px;