@micha.bigler/ui-core-micha 2.19.0 β†’ 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,4 @@
1
- import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
2
- import { Alert, Chip, IconButton, Stack } from '@mui/material';
1
+ import { Alert, Chip, Stack } from '@mui/material';
3
2
  import { useState } from 'react';
4
3
  import { useTranslation } from 'react-i18next';
5
4
 
@@ -7,18 +6,17 @@ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
6
 
8
7
  export const QUICK_EMOJIS = ['πŸ‘', '❀️', 'πŸ˜‚', 'πŸŽ‰', 'πŸ‘€'];
9
8
 
10
- /** Independently mountable aggregate reaction controls for one message. */
11
- export function ReactionBar({ message }) {
9
+ /** Compact aggregate reactions; MessageBubble opens the quick picker from its action menu. */
10
+ export function ReactionBar({ message, expanded = false, onExpandedChange, sx }) {
12
11
  const { t } = useTranslation();
13
12
  const { toggleReaction } = useMessaging();
14
- const [expanded, setExpanded] = useState(false);
15
13
  const [error, setError] = useState(null);
16
14
  // Tracks in-flight emoji toggles for this message so a rapid double-click
17
15
  // can't fire two concurrent requests for the same emoji β€” toggleReaction's
18
16
  // rollback-on-failure reverts to a pre-optimistic snapshot, which would
19
17
  // silently discard a second, already-succeeded change if both were in
20
- // flight at once. Disabling the chip while its own request is pending
21
- // removes the race instead of trying to make concurrent toggles safe.
18
+ // flight. Disabling the chip while its own request is pending removes the
19
+ // race instead of trying to make concurrent toggles safe.
22
20
  const [pending, setPending] = useState(() => new Set());
23
21
  const reactions = message?.reactions || [];
24
22
  const react = async (emoji, active = false) => {
@@ -29,17 +27,16 @@ export function ReactionBar({ message }) {
29
27
  catch (reactionError) { setError(t('MessagingReactions.ERROR', { message: extractApiErrorMessage(reactionError) })); }
30
28
  finally { setPending((current) => { const next = new Set(current); next.delete(emoji); return next; }); }
31
29
  };
32
- return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')}>
33
- <Stack direction="row" spacing={0.5} flexWrap="wrap">
30
+ return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')} sx={sx}>
31
+ {reactions.length > 0 && <Stack direction="row" spacing={0.5} flexWrap="wrap">
34
32
  {reactions.map((reaction) => <Chip key={reaction.emoji} size="small" disabled={pending.has(reaction.emoji)} color={reaction.reacted ? 'primary' : 'default'} label={`${reaction.emoji} ${reaction.count || 0}`} onClick={() => react(reaction.emoji, Boolean(reaction.reacted))} aria-label={t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 })} />)}
35
- <IconButton size="small" aria-label={t('MessagingReactions.ADD')} onClick={() => setExpanded((value) => !value)}><AddReactionOutlinedIcon fontSize="small" /></IconButton>
36
- </Stack>
33
+ </Stack>}
37
34
  {expanded && <Stack direction="row" spacing={0.25} flexWrap="wrap">{QUICK_EMOJIS.map((emoji) => {
38
35
  // A quick-emoji already reflected in the aggregate row must toggle it
39
36
  // off, not re-add it β€” otherwise clicking an already-reacted emoji here
40
37
  // optimistically double-increments the count before the API call.
41
38
  const existing = reactions.find((reaction) => reaction.emoji === emoji);
42
- return <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted)); setExpanded(false); }} />;
39
+ return <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted)); onExpandedChange?.(false); }} />;
43
40
  })}</Stack>}
44
41
  {error && <Alert severity="error" role="alert">{error}</Alert>}
45
42
  </Stack>;
@@ -1,4 +1,6 @@
1
- import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
1
+ import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
2
+ import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
3
+ import { ButtonBase, List, ListItem, Popover } from '@mui/material';
2
4
  import { useEffect, useState } from 'react';
3
5
  import { useTranslation } from 'react-i18next';
4
6
 
@@ -17,9 +19,10 @@ export function ReadTicks({ messageId, conversation }) {
17
19
  }, [getMessageReadStatus, messageId]);
18
20
  if (!status) return null;
19
21
  const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
22
+ const StatusIcon = status.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
20
23
  // The server never returns DM detail; enforce the same carve-out defensively.
21
24
  const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
22
25
  const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
23
- if (!recipients.length) return <Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography>;
24
- return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }}>{label}</ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
26
+ if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={status.all_read ? 'primary' : 'inherit'} />;
27
+ return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color: status.all_read ? 'primary.main' : 'inherit' }}><StatusIcon aria-hidden="true" /></ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
25
28
  }
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : options?.sender ? `${key}:${options.sender}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AuthContext } from '../src/auth/AuthContext';
10
+ import { MessageBubble } from '../src/messaging/MessageBubble';
11
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
12
+ import { ReadTicks } from '../src/messaging/ReadTicks';
13
+
14
+ const api = () => ({
15
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
16
+ getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
17
+ votePoll: vi.fn(), closePoll: vi.fn(),
18
+ });
19
+ function renderBubble(message, { conversation = { kind: 'group' }, replyTo, onJumpToMessage, child, ownId = 1 } = {}) {
20
+ return render(<AuthContext.Provider value={{ user: { id: ownId } }}><MessagingProvider api={api()} active={false}><MessageBubble message={message} conversation={conversation} replyTo={replyTo} onJumpToMessage={onJumpToMessage}>{child}</MessageBubble></MessagingProvider></AuthContext.Provider>);
21
+ }
22
+
23
+ afterEach(cleanup);
24
+
25
+ describe('compact message bubbles', () => {
26
+ it('keeps the timestamp and own-message status icon inside the bubble and has no standalone plain-message controls', async () => {
27
+ const { container } = renderBubble({ id: 1, body: 'Hallo', sender: { id: 1, display_name: 'Me' }, created_at: '2026-08-02T10:00:00Z' }, { child: <ReadTicks messageId={1} conversation={{ kind: 'group' }} /> });
28
+ const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
29
+ const paper = container.querySelector('[data-message-id="1"] .MuiPaper-root');
30
+ expect(paper.contains(tick)).toBe(true);
31
+ expect(paper.querySelector('[data-testid="message-meta"]')).toBeTruthy();
32
+ expect(screen.queryByText('MessagingThread.REPLY')).toBeNull();
33
+ expect(screen.queryByLabelText('MessagingReactions.LABEL')).toBeNull();
34
+ });
35
+
36
+ it('marks own and incoming bubbles differently and only shows a sender in non-direct conversations', () => {
37
+ const { container, rerender } = renderBubble({ id: 2, body: 'Mine', sender: { id: 1, display_name: 'Me' } });
38
+ expect(container.querySelector('[data-message-id="2"]').getAttribute('data-message-side')).toBe('own');
39
+ expect(screen.getByText('Me')).toBeTruthy();
40
+ rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 3, body: 'Theirs', sender: { id: 9, display_name: 'Ava' } }} conversation={{ kind: 'direct' }} /></MessagingProvider></AuthContext.Provider>);
41
+ expect(container.querySelector('[data-message-id="3"]').getAttribute('data-message-side')).toBe('incoming');
42
+ expect(screen.queryByText('Ava')).toBeNull();
43
+ });
44
+
45
+ it('keeps a reply quote clickable inside the bubble', () => {
46
+ const onJumpToMessage = vi.fn();
47
+ const { container } = renderBubble({ id: 5, body: 'Reply', sender: { id: 1 } }, { replyTo: { id: 4, body: 'Quoted words', sender: { display_name: 'Ava' } }, onJumpToMessage });
48
+ fireEvent.click(screen.getByText('Quoted words'));
49
+ expect(onJumpToMessage).toHaveBeenCalledWith(4);
50
+ expect(container.querySelector('[data-message-id="5"] .MuiPaper-root').contains(screen.getByText('Quoted words'))).toBe(true);
51
+ });
52
+
53
+ it('continues to render announcement, poll and attachment content inside the bubble', async () => {
54
+ const { container, rerender } = renderBubble({ id: 6, kind: 'announcement', title: 'Notice', body: 'Update', sender: { id: 1 } });
55
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Notice'))).toBe(true);
56
+ rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 7, kind: 'poll', body: 'Poll', sender: { id: 1 }, poll: { id: 1, question: 'Where?', options: [], allow_multiple: false } }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
57
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Where?'))).toBe(true);
58
+ rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 8, body: 'File', sender: { id: 1 }, attachments: [{ id: 1, filename: 'notes.pdf', content_type: 'application/pdf' }] }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
59
+ await waitFor(() => expect(screen.getByText(/MessagingAttachments\.DOWNLOAD/)).toBeTruthy());
60
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText(/MessagingAttachments\.DOWNLOAD/))).toBe(true);
61
+ });
62
+ });
@@ -55,6 +55,47 @@ describe('DirectMessageLauncher', () => {
55
55
  await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
56
56
  });
57
57
 
58
+ it('uses the selected candidate scope over the launcher scope', () => {
59
+ const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
60
+ renderLauncher({
61
+ api,
62
+ candidates: [
63
+ { id: 2, display_name: 'Alex', scope: 'scope-a' },
64
+ { id: 3, display_name: 'Sam', scope: 'scope-b' },
65
+ ],
66
+ scope: 'scope-prop',
67
+ });
68
+ openPicker();
69
+ fireEvent.click(screen.getByText('Sam'));
70
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
71
+ expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 3, scope: 'scope-b' });
72
+ });
73
+
74
+ it('falls back to the launcher scope when the selected candidate has none', () => {
75
+ const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
76
+ renderLauncher({
77
+ api,
78
+ candidates: [
79
+ { id: 2, display_name: 'Alex' },
80
+ { id: 3, display_name: 'Sam', scope: 'scope-b' },
81
+ ],
82
+ scope: 'scope-prop',
83
+ });
84
+ openPicker();
85
+ fireEvent.click(screen.getByText('Alex'));
86
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
87
+ expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 2, scope: 'scope-prop' });
88
+ });
89
+
90
+ it('omits scope when neither the candidate nor launcher provides one', () => {
91
+ const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
92
+ renderLauncher({ api });
93
+ openPicker();
94
+ fireEvent.click(screen.getByText('Alex'));
95
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
96
+ expect(Object.keys(api.createDirectConversation.mock.calls[0][0])).toEqual(['target_user_id']);
97
+ });
98
+
58
99
  it('surfaces a policy rejection without closing the picker', async () => {
59
100
  const api = { createDirectConversation: vi.fn().mockRejectedValue({ response: { data: { detail: 'Direct messages are restricted.' } } }) };
60
101
  renderLauncher({ api });
@@ -17,8 +17,8 @@ function makeApi() {
17
17
  deleteMessage: vi.fn().mockImplementation(async (id) => ({ id, deleted_at: '2026-07-31T10:01:00Z' })),
18
18
  };
19
19
  }
20
- function renderBubble(message, { userId = 1, canModerateMessages = false, api = makeApi() } = {}) {
21
- const result = render(<AuthContext.Provider value={{ user: { id: userId } }}><MessagingProvider api={api} active={false}><MessageBubble message={message} canModerateMessages={canModerateMessages} /></MessagingProvider></AuthContext.Provider>);
20
+ function renderBubble(message, { userId = 1, canModerateMessages = false, api = makeApi(), onReply } = {}) {
21
+ const result = render(<AuthContext.Provider value={{ user: { id: userId } }}><MessagingProvider api={api} active={false}><MessageBubble message={message} canModerateMessages={canModerateMessages} onReply={onReply} /></MessagingProvider></AuthContext.Provider>);
22
22
  return { ...result, api };
23
23
  }
24
24
  function openActions() { fireEvent.click(screen.getByRole('button', { name: 'MessagingActions.MENU' })); }
@@ -26,19 +26,89 @@ function openActions() { fireEvent.click(screen.getByRole('button', { name: 'Mes
26
26
  afterEach(cleanup);
27
27
 
28
28
  describe('message actions', () => {
29
- it('gates the menu to the author or host-supplied moderator capability, never deleted or poll messages', () => {
29
+ it('offers Reply, React and Copy for incoming messages, adding Edit and Delete only for authors or moderators', () => {
30
30
  const mine = { id: 1, body: 'Mine', sender: { id: 1 } };
31
31
  const theirs = { id: 2, body: 'Theirs', sender: { id: 2 } };
32
- const { rerender } = renderBubble(mine);
32
+ const { unmount } = renderBubble(mine);
33
33
  expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
34
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={theirs} /></MessagingProvider></AuthContext.Provider>);
35
- expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
36
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={theirs} canModerateMessages /></MessagingProvider></AuthContext.Provider>);
34
+ openActions();
35
+ ['MessagingThread.REPLY', 'MessagingReactions.ADD', 'MessagingActions.EDIT', 'MessagingActions.DELETE', 'MessagingActions.COPY'].forEach((name) => expect(screen.getByRole('menuitem', { name })).toBeTruthy());
36
+ fireEvent.click(screen.getByRole('menuitem', { name: 'MessagingReactions.ADD' }));
37
+ expect(screen.getByLabelText('MessagingReactions.LABEL')).toBeTruthy();
38
+ expect(screen.getByText('πŸ‘')).toBeTruthy();
39
+ unmount();
40
+ renderBubble(theirs);
37
41
  expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
38
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ ...mine, deleted_at: '2026-07-31T10:00:00Z' }} /></MessagingProvider></AuthContext.Provider>);
39
- expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
40
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ ...mine, kind: 'poll' }} /></MessagingProvider></AuthContext.Provider>);
42
+ openActions();
43
+ ['MessagingThread.REPLY', 'MessagingReactions.ADD', 'MessagingActions.COPY'].forEach((name) => expect(screen.getByRole('menuitem', { name })).toBeTruthy());
44
+ expect(screen.queryByRole('menuitem', { name: 'MessagingActions.EDIT' })).toBeNull();
45
+ expect(screen.queryByRole('menuitem', { name: 'MessagingActions.DELETE' })).toBeNull();
46
+ cleanup();
47
+ renderBubble(theirs, { canModerateMessages: true });
48
+ expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
49
+ cleanup();
50
+ renderBubble({ ...mine, deleted_at: '2026-07-31T10:00:00Z' });
41
51
  expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
52
+ cleanup();
53
+ renderBubble({ ...mine, kind: 'poll' });
54
+ expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
55
+ });
56
+
57
+ it('opens the affordance from a tap and lets a keyboard user reveal and operate it', () => {
58
+ const onReply = vi.fn();
59
+ const { container } = renderBubble({ id: 3, body: 'Touch me', sender: { id: 2 } }, { onReply });
60
+ const bubble = container.querySelector('[data-message-id="3"] .MuiPaper-root');
61
+ fireEvent.click(bubble);
62
+ expect(bubble.getAttribute('data-actions-visible')).toBe('true');
63
+ const actions = screen.getByRole('button', { name: 'MessagingActions.MENU' });
64
+ fireEvent.focus(actions);
65
+ fireEvent.click(actions);
66
+ fireEvent.click(screen.getByRole('menuitem', { name: 'MessagingThread.REPLY' }));
67
+ expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
68
+ });
69
+
70
+ it('dismisses the tap-revealed affordance again on an outside interaction, but not on a reaction-picker click inside the same bubble', () => {
71
+ const { container } = renderBubble({ id: 3, body: 'Touch me', sender: { id: 2 }, reactions: [{ emoji: 'πŸ‘', count: 1, reacted: false }] });
72
+ const bubble = container.querySelector('[data-message-id="3"] .MuiPaper-root');
73
+ fireEvent.click(bubble);
74
+ expect(bubble.getAttribute('data-actions-visible')).toBe('true');
75
+ // A pointerdown inside the same message's own reaction row (a sibling of
76
+ // the Paper, still within the article) must not count as "outside" β€”
77
+ // otherwise every reaction click would prematurely hide the affordance.
78
+ fireEvent.pointerDown(screen.getByText('πŸ‘ 1'));
79
+ expect(bubble.getAttribute('data-actions-visible')).toBe('true');
80
+ // A pointerdown genuinely outside the message dismisses it β€” this is the
81
+ // U1 fix: without it, the affordance stayed pinned visible forever after
82
+ // any click that bubbled up to the bubble (Part A's "no visible
83
+ // affordance at rest" requirement).
84
+ fireEvent.pointerDown(document.body);
85
+ expect(bubble.getAttribute('data-actions-visible')).toBeNull();
86
+ });
87
+
88
+ it('keeps each message bubble\'s tap-reveal state independent β€” revealing one does not dismiss another', () => {
89
+ const api = makeApi();
90
+ const { container } = render(
91
+ <AuthContext.Provider value={{ user: { id: 1 } }}>
92
+ <MessagingProvider api={api} active={false}>
93
+ <MessageBubble message={{ id: 10, body: 'First', sender: { id: 2 } }} />
94
+ <MessageBubble message={{ id: 11, body: 'Second', sender: { id: 2 } }} />
95
+ </MessagingProvider>
96
+ </AuthContext.Provider>
97
+ );
98
+ const bubbleA = container.querySelector('[data-message-id="10"] .MuiPaper-root');
99
+ const bubbleB = container.querySelector('[data-message-id="11"] .MuiPaper-root');
100
+ fireEvent.pointerDown(bubbleA);
101
+ fireEvent.click(bubbleA);
102
+ expect(bubbleA.getAttribute('data-actions-visible')).toBe('true');
103
+ expect(bubbleB.getAttribute('data-actions-visible')).toBeNull();
104
+ // Tapping bubble B is, from bubble A's perspective, an outside
105
+ // interaction β€” A dismisses, B reveals; the two must never share state.
106
+ // (A real tap fires pointerdown before click; fireEvent.click alone
107
+ // wouldn't exercise the pointerdown-driven dismiss listener.)
108
+ fireEvent.pointerDown(bubbleB);
109
+ fireEvent.click(bubbleB);
110
+ expect(bubbleA.getAttribute('data-actions-visible')).toBeNull();
111
+ expect(bubbleB.getAttribute('data-actions-visible')).toBe('true');
42
112
  });
43
113
 
44
114
  it('edits an author message through the API and auto-cancels when deleted elsewhere', async () => {
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
 
6
6
  vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.emoji ? `${key}:${options.emoji}:${options.count}` : key }) }));
7
7
 
@@ -29,7 +29,7 @@ function baseApi(overrides = {}) {
29
29
  // a live-frame test must read the message back out of the cache.
30
30
  function ConnectedReactionBar({ messageId }) {
31
31
  const { cache } = useMessaging();
32
- return <ReactionBar message={cache.messages[messageId]} />;
32
+ return <ReactionBar message={cache.messages[messageId]} expanded />;
33
33
  }
34
34
 
35
35
  afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
@@ -54,9 +54,7 @@ describe('reaction frame/REST merge against the real aggregate-only contract', (
54
54
  it('the viewer\'s own optimistic reacted flag survives its own REST confirmation, which returns an aggregate-only shape', async () => {
55
55
  const api = baseApi({ addReaction: vi.fn().mockResolvedValue({ reactions: [{ emoji: 'πŸ‘', count: 1 }] }) });
56
56
  render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
57
- dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } });
58
- await waitFor(() => screen.getByLabelText('MessagingReactions.ADD'));
59
- fireEvent.click(screen.getByLabelText('MessagingReactions.ADD'));
57
+ act(() => { dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } }); });
60
58
  fireEvent.click(screen.getByText('πŸ‘'));
61
59
  await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith('msg-1', 'πŸ‘'));
62
60
  await waitFor(() => {
@@ -68,7 +68,8 @@ describe('messaging threading and quoting', () => {
68
68
  api.deleteMessage = vi.fn().mockResolvedValue({});
69
69
  render(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider></AuthContext.Provider>);
70
70
  await screen.findByText('Reply target');
71
- fireEvent.click(screen.getByRole('button', { name: /MessagingThread\.REPLY\b/ }));
71
+ fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
72
+ fireEvent.click(screen.getByRole('menuitem', { name: /MessagingThread\.REPLY\b/ }));
72
73
  expect(screen.getByText(/MessagingThread\.REPLYING_TO/)).toBeTruthy();
73
74
  fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
74
75
  fireEvent.click(screen.getByRole('menuitem', { name: /MessagingActions\.DELETE/ }));
@@ -1,6 +1,9 @@
1
1
  # DX-1 β€” minimal Vite dev harness for ucm components
2
2
 
3
- Status: planned Β· Tier 1 (dev-only tooling; **adds devDependencies β†’ approval-gated**) Β· Target repo: `ui-core-micha` (main)
3
+ Status: done Β· **Tier 2** (dev-only tooling; **adds devDependencies β†’ approval-gated**) Β· Target repo: `ui-core-micha` (main)
4
+ *Reclassified 2026-08-01: labelled Tier 1 at authoring, but it adds dependencies in a shared-core
5
+ repo β€” two binding Tier-2 surfaces per AGENTS.md's Tiering gate. A named independent `reviewer` did
6
+ run (one P2 on the provider's own WebSocket), so only the label was wrong.*
4
7
 
5
8
  ---
6
9
 
@@ -0,0 +1,164 @@
1
+ # MSG-3e β€” per-candidate scope in `DirectMessageLauncher`
2
+
3
+ Status: planned Β· Tier 2 Β· Target repo: `ui-core-micha` (main)
4
+
5
+ ---
6
+
7
+ ## Part A β€” Envelope (2026-08-02)
8
+
9
+ ### Goal
10
+
11
+ Let a host present **one flat direct-message picker whose candidates live in different messaging
12
+ scopes**. Today `DirectMessageLauncher` takes a single `scope` prop and applies it to every candidate,
13
+ so a host with people spread across several scopes must either render one launcher per scope or
14
+ re-implement the component.
15
+
16
+ ### Why now
17
+
18
+ jg-ferien MSG-6 turns `/messages` into a global inbox across **all** of a user's events. In jg's model a
19
+ direct conversation is scoped to an event, so a flat "everyone I may DM" list carries a **different
20
+ scope per person** β€” the operator's explicit requirement ("DM ΓΌber alle, Gruppen pro Event"). Without
21
+ this seam jg would have to copy ~90 lines of this component locally, which AGENTS.md's
22
+ reuse-before-reimplement rule exists to prevent. The need is generic: any host whose people span
23
+ multiple scopes hits it.
24
+
25
+ ### Expected outcome
26
+
27
+ `candidates[n].scope` (optional) takes precedence over the `scope` prop **for the selected candidate
28
+ only**. Precisely:
29
+
30
+ | selected candidate's `scope` | `scope` prop | scope sent to `createDirectConversation` |
31
+ |---|---|---|
32
+ | present | present | the candidate's |
33
+ | present | absent | the candidate's |
34
+ | absent | present | the prop's (today's behaviour) |
35
+ | absent | absent | no `scope` key in the payload at all (today's behaviour) |
36
+
37
+ Strictly additive and backward compatible: a host that passes no per-candidate scope sees byte-identical
38
+ behaviour. Note the last row β€” the current code omits the key entirely rather than sending
39
+ `scope: null`, and that must not change; dcm distinguishes the two.
40
+
41
+ ### Scope / non-goals
42
+
43
+ In scope: `src/messaging/DirectMessageLauncher.jsx` and its test file.
44
+
45
+ Explicit non-goals β€” do **not**:
46
+ - change the dialog's layout, copy, i18n keys, or a11y semantics;
47
+ - add grouping, sectioning, or per-candidate secondary text (a scope is not a label β€” jg groups its
48
+ *group* picker by event locally, and that stays a host concern);
49
+ - touch `MessagingProvider`'s `openDirectConversation` signature or any other component;
50
+ - validate or interpret the scope value β€” it is an opaque host-supplied identifier, passed through.
51
+
52
+ ### Tier
53
+
54
+ Tier 2. Shared-core (`ui-core-micha`) is excluded from the Tier-1 fast path by AGENTS.md regardless of
55
+ diff size, so this one-line-of-logic change gets a WO, Codex-first, and a mandatory independent review.
56
+
57
+ ### Tests to WRITE (scoped β€” run only these)
58
+
59
+ Extend `tests/messagingDirectMessageLauncher.test.jsx`:
60
+
61
+ 1. **per-candidate scope wins** β€” candidates `[{id: 2, display_name: 'Alex', scope: 'scope-a'}, {id: 3,
62
+ display_name: 'Sam', scope: 'scope-b'}]`, launcher `scope="scope-prop"`; select Sam, start; assert
63
+ `createDirectConversation` called with `{target_user_id: 3, scope: 'scope-b'}`.
64
+ 2. **fallback to the prop** (regression) β€” a candidate without its own `scope` in the same mixed list
65
+ still sends the prop's scope.
66
+ 3. **neither present** β€” no `scope` key in the payload (`expect(Object.keys(payload)).toEqual(
67
+ ['target_user_id'])` or equivalent β€” asserting absence, not `undefined`).
68
+
69
+ The three existing tests in that file must keep passing unchanged; they are the backward-compatibility
70
+ proof and must not be edited to accommodate the change.
71
+
72
+ Command: `pnpm vitest run tests/messagingDirectMessageLauncher.test.jsx`
73
+
74
+ ### Release
75
+
76
+ One version bump `2.19.0` β†’ **`2.20.0`** (minor: additive, backward-compatible) + npm publish at WO end.
77
+ jg-ferien MSG-6 pins this version, so the publish is the precondition for that WO's final test run.
78
+ Per `reference_dcm_ucm_publish_topology`: publish-from-main, and the target version must be verified
79
+ live on the registry before jg bumps its pin.
80
+
81
+ ### Risks
82
+
83
+ Low. The one real trap is the "absent/absent" row above β€” a naive `scope: candidate.scope ?? scope`
84
+ inside the object literal would start sending `scope: undefined`, which serialises differently and
85
+ changes an existing contract. Build the payload conditionally, as the current code already does.
86
+
87
+ ### Execution directive
88
+
89
+ **If you are the implementer reading this as your own spec, this section is not addressed to you β€” it
90
+ tells the Orchestrator how to invoke you. You ARE that invocation: do not shell out to `codex exec`.**
91
+
92
+ Orchestrator: implement through `codex exec` in the background, invoked **directly via Bash** (never the
93
+ `debugger`/`*_coder` Agent wrappers), with **both** flags `--skip-git-repo-check` and
94
+ `--dangerously-bypass-approvals-and-sandbox`, the WO passed as a positional argument from this committed
95
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit; if the
96
+ fallback is used the Orchestrator becomes author and an independent `reviewer` is mandatory.
97
+
98
+ ### Mini-handover (pastable)
99
+
100
+ Orchestrator: implement `work-orders/MSG-3e.md` in `ui-core-micha` (main). `git pull` first, read the WO,
101
+ then follow `orchestrate-codex` (Codex-first, own independent review, publish 2.20.0 at WO end).
102
+
103
+ ---
104
+
105
+ ## Part B β€” Implementation map (Orchestrator)
106
+
107
+ **Single chunk.** **Target repo working directory:** `C:\Users\Micha Bigler\Documents\webapps\ui-core-micha`
108
+ (repo root). Work from this package; open only the named files to verify.
109
+
110
+ ### The one file that changes
111
+
112
+ `src/messaging/DirectMessageLauncher.jsx` β€” 96 lines, whole file is in scope but only `start()` and one
113
+ derivation change.
114
+
115
+ Current state (verified while scoping, do not re-derive):
116
+
117
+ - `:25` β€” `export function DirectMessageLauncher({ candidates = [], scope, onOpen })`.
118
+ - `:31` β€” the component tracks **only** `selectedId`, never the selected candidate object. A lookup is
119
+ therefore needed; mirror the pattern jg already uses in its own `AllGroupsLauncher`
120
+ (`jg-ferien/frontend/src/pages/messaging/AllGroupsLauncher.jsx:40-44`):
121
+ `const selectedCandidate = useMemo(() => candidates.find((c) => c.id === selectedId) || null,
122
+ [candidates, selectedId]);` β€” `useMemo` is not yet imported in this file (`:1` imports `useId, useState`).
123
+ - `:44-60` β€” `start()`; the payload is built at `:49-52`:
124
+ ```js
125
+ const conversation = await openDirectConversation({
126
+ target_user_id: selectedId,
127
+ ...(scope == null ? {} : { scope }),
128
+ });
129
+ ```
130
+ This spread-or-nothing shape is the invariant the third test pins: when there is no scope the key is
131
+ **absent**, not `undefined`. Keep the shape; only the value fed into it changes:
132
+ `const effectiveScope = selectedCandidate?.scope ?? scope;` then `...(effectiveScope == null ? {} :
133
+ { scope: effectiveScope })`. `??` (not `||`) so a falsy-but-valid scope id survives.
134
+ - `:75-82` β€” the `ListItemButton` render loop; **do not** change it. Candidate rendering already falls
135
+ back through `display_name || name || label || id` and the scope must not appear in the UI.
136
+
137
+ ### Invariants / do-not-touch
138
+
139
+ - `extractApiErrorMessage` error path (`:55-59`) and the `starting` guard stay exactly as they are.
140
+ - `openDirectConversation` comes from `useMessaging()` (`:19, :27`) β€” its signature is dcm's contract,
141
+ unchanged.
142
+ - `src/index.js:72` already exports the component; no export change.
143
+ - No new i18n keys (`MessagingDirect.*` set is complete for this change).
144
+
145
+ ### Docs
146
+
147
+ `docs/messaging-deviations.md` currently has zero BLOCKED entries (MSG-3d closed them) β€” this WO adds no
148
+ deviation and must not add a row. If the repo carries a prop-level API note for this component, update it;
149
+ if not, do not invent a docs file.
150
+
151
+ ### Progress contract
152
+
153
+ Emit a `PLAN: …` line first, then a single-line `PROGRESS: [n/total] <action>` before every relevant
154
+ action and `… done` on completion, no gap > ~2 min, unbuffered stdout, and exactly one final
155
+ `RESULT: DONE|BLOCKED <reason>`.
156
+
157
+ ### Constraints for the implementer
158
+
159
+ The WO text is the COMPLETE spec. Read `AGENTS.md` and `.codex/skills/frontend-engineering` only for
160
+ conventions. Stay in scope. Do not touch the Core-Behaviour approval surfaces. Do not update `MEMORY.md`.
161
+ Do **not** `git add/commit/push` β€” leave the diff for the Orchestrator's review. Do **not** bump the
162
+ version or publish β€” the Orchestrator owns the release. WRITE the three tests above and RUN ONLY that one
163
+ test file; no full suite, no self-review β€” the Orchestrator owns the authoritative run and the
164
+ independent review, and those are the gate.