diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index b582f56..e785610 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.4", + "version": "0.1.5", "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 3ea31f5..3c72ace 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -80,4 +80,17 @@ export interface MessagingAdapter { /** Leave a channel by id. */ leaveChannel?(threadId: string): Promise; + + // ── 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; + + /** Remove a person from a group or channel. */ + removeMember?(threadId: string, userId: string): Promise; + + /** Rename a group or channel. */ + renameConversation?(threadId: string, subject: string): Promise; } diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index e75b370..b114d7c 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -163,6 +163,21 @@ export class MockAdapter implements MessagingAdapter { return MOCK_PEOPLE.map((p) => ({ ...p })); } + async addMember(threadId: string, userId: string): Promise { + const t = this.threads.get(threadId); + if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId]; + } + + async removeMember(threadId: string, userId: string): Promise { + const t = this.threads.get(threadId); + if (t) t.participants = t.participants.filter((p) => p !== userId); + } + + async renameConversation(threadId: string, subject: string): Promise { + 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) { diff --git a/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx b/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx new file mode 100644 index 0000000..3fa1c01 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx @@ -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( + + {}} /> + , + ); + 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('', () => { + 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); + }); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/conversation-settings.tsx b/packages/iios-messaging-ui/src/components/conversation-settings.tsx new file mode 100644 index 0000000..2f627b7 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/conversation-settings.tsx @@ -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([]); + const [directory, setDirectory] = useState([]); + const [name, setName] = useState(title); + const [q, setQ] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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([]), + adapter.directory ? adapter.directory() : Promise.resolve([]), + ]).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): Promise { + 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 ( + +
+
e.stopPropagation()}> +
+ {membership === 'channel' ? 'Channel' : 'Group'} settings + +
+
+ {canRename ? ( +
+ Name +
+ setName(e.target.value)} aria-label="Conversation name" /> + +
+
+ ) : null} + +
+ Members · {members.length} +
+ {members.map((m) => ( +
+ {m.name} + {m.kind} + {canManage ? ( + + ) : null} +
+ ))} +
+
+ + {canManage ? ( +
+ Add people + setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" /> +
+ {addable.length === 0 ?
No one to add.
: null} + {addable.slice(0, 25).map((p) => ( + + ))} +
+
+ ) : null} + + {error ?
{error}
: null} +
+
+ {canLeave ? ( + + ) : ( + + )} + +
+
+
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/message-item.tsx b/packages/iios-messaging-ui/src/components/message-item.tsx index 7cb7121..6b5e243 100644 --- a/packages/iios-messaging-ui/src/components/message-item.tsx +++ b/packages/iios-messaging-ui/src/components/message-item.tsx @@ -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 ? : null} +
{canReact ? (
diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx index 2b7e54e..e28c2b7 100644 --- a/packages/iios-messaging-ui/src/components/messenger.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -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() { }} /> ) : ( - + { + refetch(); + setSelected(null); + }} + /> )} diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx index 0f21efa..8526365 100644 --- a/packages/iios-messaging-ui/src/components/thread.tsx +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -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 (
+ {conversation ? ( +
+ + {membership === 'channel' ? '# ' : ''} + {conversation.title || 'Conversation'} + + {manageable ? ( + + ) : null} +
+ ) : null}
{loading && messages.length === 0 ?
Loading…
: null} {error ?
{error}
: null} @@ -56,6 +79,16 @@ export function Thread({
+ + {settingsOpen && manageable && conversation ? ( + setSettingsOpen(false)} + {...(onLeft ? { onLeft } : {})} + /> + ) : null}
); } diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 207b3e2..fc6a0bc 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -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;