@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.
- package/WORK_ORDERS.md +4 -2
- package/dev/entries.jsx +12 -5
- package/dist/messaging/DirectMessageLauncher.js +5 -2
- package/dist/messaging/MessageBubble.js +46 -5
- package/dist/messaging/ReactionBar.js +7 -9
- package/dist/messaging/ReadTicks.js +6 -3
- package/package.json +1 -1
- package/src/messaging/DirectMessageLauncher.jsx +7 -2
- package/src/messaging/MessageBubble.jsx +82 -32
- package/src/messaging/ReactionBar.jsx +9 -12
- package/src/messaging/ReadTicks.jsx +6 -3
- package/tests/messagingCompactBubble.test.jsx +62 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +41 -0
- package/tests/messagingMessageActions.test.jsx +80 -10
- package/tests/messagingReactionMerge.test.jsx +3 -5
- package/tests/messagingThreadingQuoting.test.jsx +2 -1
- package/work-orders/DX-1.md +4 -1
- package/work-orders/MSG-3e.md +164 -0
- package/work-orders/MSG-3f.md +391 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
/**
|
|
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
|
|
21
|
-
//
|
|
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
|
-
|
|
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));
|
|
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
|
|
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 <
|
|
24
|
-
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{
|
|
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('
|
|
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 {
|
|
32
|
+
const { unmount } = renderBubble(mine);
|
|
33
33
|
expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
|
|
34
|
-
|
|
35
|
-
expect(screen.
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
expect(screen.
|
|
40
|
-
|
|
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: /
|
|
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/ }));
|
package/work-orders/DX-1.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# DX-1 β minimal Vite dev harness for ucm components
|
|
2
2
|
|
|
3
|
-
Status:
|
|
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.
|