@micha.bigler/ui-core-micha 2.23.0 → 2.25.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.
@@ -136,7 +136,13 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
136
136
  </Stack>}
137
137
  </Stack>
138
138
  </Paper>
139
- {!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }} />}
139
+ {/* A small positive gap, not the negative-margin "peek from the bottom
140
+ edge" trick this used to have -- that pull was tuned against a
141
+ plain text line and read as an unnatural collision once the
142
+ bubble's actual bottom content became variable (an attachment
143
+ row's bordered Button, or MSG-6g's poll bars), both visually
144
+ boxier than text and closer to the bubble's edge. */}
145
+ {!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: 0.5, ml: 0.75 }} />}
140
146
  <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
141
147
  <MenuItem onClick={startReply}>{t('MessagingThread.REPLY')}</MenuItem>
142
148
  <MenuItem onClick={startReact}>{t('MessagingReactions.ADD')}</MenuItem>
Binary file
@@ -45,7 +45,18 @@ export function PollCard({ message, canClose: canCloseProp }) {
45
45
  const closed = Boolean(poll.closed_at);
46
46
  const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
47
47
  const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
48
- const totalVotes = (poll.options || []).reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0);
48
+ // Percentage denominator (MSG-6i): the number of distinct people who
49
+ // answered this poll, not sum(vote_count) across options. For a
50
+ // multi-select poll one person can vote for several options, so summing
51
+ // vote_count can exceed the actual respondent count -- dividing by that
52
+ // inflated sum understated everyone's percentage and the bars didn't sum
53
+ // to a sensible 100%. dcm's serialize_poll always includes each option's
54
+ // `voters` (a list of user ids, ungated -- unlike messaging read
55
+ // receipts); the union across all options is exactly "everyone who
56
+ // answered", computed client-side, no backend change. This deliberately
57
+ // reverses MSG-6g's own explicit non-goal ("denominator = votes cast, not
58
+ // participant count") -- an operator-confirmed correction, not a revert.
59
+ const respondentCount = new Set((poll.options || []).flatMap((option) => option.voters || [])).size;
49
60
 
50
61
  const tap = async (id) => {
51
62
  if (closed) return;
@@ -87,25 +98,29 @@ export function PollCard({ message, canClose: canCloseProp }) {
87
98
  // is no user-directory endpoint (same structural limit already
88
99
  // established for the DM candidate list).
89
100
  const voteCount = option.vote_count ?? option.voters?.length ?? 0;
90
- const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
101
+ const percent = respondentCount > 0 ? Math.round((voteCount / respondentCount) * 100) : 0;
91
102
  const votedByMe = selected.includes(option.id);
92
103
  const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
93
104
  // The bar is decorative; count/percent live in the accessible name (via
94
105
  // aria-label on the control itself) as well as in adjacent visible text —
95
106
  // width alone must never be the only signal (WO risk note).
96
107
  const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
97
- const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.25}>
108
+ // MSG-6i scope B: "airier, not wider" (operator, after seeing a
109
+ // screenshot of the checkbox/text/percent squeezed onto one tight line
110
+ // with a thin bar close to the text) -- more internal spacing/a taller
111
+ // bar, not a wider bubble (MessageBubble.jsx's maxWidth is untouched).
112
+ const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.75}>
98
113
  <Stack direction="row" justifyContent="space-between">
99
114
  <Typography variant="body2" noWrap>{option.text || option.label}</Typography>
100
115
  <Typography variant="caption" color="text.secondary">{resultText}</Typography>
101
116
  </Stack>
102
- <LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 6 }} color={votedByMe ? 'primary' : 'inherit'} />
117
+ <LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 8 }} color={votedByMe ? 'primary' : 'inherit'} />
103
118
  </Stack>;
104
119
  return { accessibleLabel, richLabel };
105
120
  };
106
121
  const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
107
122
 
108
- return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.75}>
123
+ return <Paper variant="outlined" sx={{ p: 1.5 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={1.5}>
109
124
  <Typography variant="subtitle2">{poll.question}</Typography>
110
125
  {poll.allow_multiple
111
126
  ? (poll.options || []).map((option) => {
@@ -139,10 +139,21 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
139
139
  setOpenThreads((current) => ({ ...current, [root.id]: !current[root.id] }));
140
140
  };
141
141
  return (
142
- <Stack spacing={1} sx={{ minWidth: 0 }}>
142
+ // flex: 1, minHeight: 0 (here and on the scroll Box below): lets Thread
143
+ // shrink to fit when a host embeds it inside a fixed-height flex-column
144
+ // layout (e.g. header + Thread + Composer all fitting one screen, no
145
+ // page-level scroll) -- a flex item's default min-height is 'auto',
146
+ // which refuses to shrink below content size without this. Both
147
+ // properties are no-ops outside a flex context, so a host that doesn't
148
+ // flex-wrap Thread sees no behaviour change at all; maxHeight below
149
+ // still caps growth the same as before for that case (MSG-18, found
150
+ // while investigating why jg-ferien's Composer rendered behind its
151
+ // mobile bottom nav -- Thread's own fixed 560px cap never shrank for a
152
+ // short mobile viewport, no matter how the host's own layout was fixed).
153
+ <Stack spacing={1} sx={{ minWidth: 0, flex: 1, minHeight: 0 }}>
143
154
  {error && <Alert severity="error" role="alert">{error}</Alert>}
144
155
  {replyingTo && <Alert severity="info" onClose={() => { setReplyingTo(null); onReplyTargetChange?.(null); }}>{t('MessagingThread.REPLYING_TO', { sender: replyingTo.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
145
- <Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
156
+ <Box ref={scrollRef} onScroll={onScroll} sx={{ flex: 1, minHeight: 0, maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
146
157
  {cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
147
158
  {/* Only show the initial-load spinner when there's nothing to show yet
148
159
  — re-opening a conversation whose messages are already cached (an
@@ -56,7 +56,9 @@ describe('compact message bubbles', () => {
56
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
57
  expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Where?'))).toBe(true);
58
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);
59
+ // MSG-6h: a non-image attachment's visible content is now its filename +
60
+ // a file icon (the download semantics live in the accessible name).
61
+ await waitFor(() => expect(screen.getByText('notes.pdf')).toBeTruthy());
62
+ expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('notes.pdf'))).toBe(true);
61
63
  });
62
64
  });
@@ -90,10 +90,13 @@ describe('Composer chunk 5 behavior', () => {
90
90
  });
91
91
 
92
92
  it('inserts the selected emoji at the current cursor position', async () => {
93
+ // MSG-6i: the Composer's own picker is now a wider curated grid
94
+ // (IconButton per emoji), decoupled from ReactionBar's 5-entry
95
+ // QUICK_EMOJIS -- role is "button", not "menuitem".
93
96
  renderComposer(); fireEvent.change(messageInput(), { target: { value: 'abCD' } });
94
97
  messageInput().setSelectionRange(2, 2);
95
98
  fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
96
- fireEvent.click(await screen.findByRole('menuitem', { name: '👍' }));
99
+ fireEvent.click(await screen.findByRole('button', { name: '👍' }));
97
100
  await waitFor(() => expect(messageInput().value).toBe('ab👍CD'));
98
101
  });
99
102
 
@@ -0,0 +1,136 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AttachmentList } from '../src/messaging/AttachmentList';
10
+ import { AuthContext } from '../src/auth/AuthContext';
11
+ import { MessageBubble } from '../src/messaging/MessageBubble';
12
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
13
+
14
+ const imageAttachment = { id: 'att-1', filename: 'vacation-photo.jpg', content_type: 'image/jpeg' };
15
+ const fileAttachment = { id: 'att-2', filename: 'Report.pdf', content_type: 'application/pdf' };
16
+ const fallbackAttachment = { id: 'att-3', content_type: 'application/pdf' }; // no filename -- pre-MSG-12 dcm pin
17
+
18
+ function makeApi(overrides = {}) {
19
+ return {
20
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
21
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
22
+ getAttachment: vi.fn().mockResolvedValue(new Blob(['full-bytes'], { type: 'image/jpeg' })),
23
+ getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['thumb-bytes'], { type: 'image/jpeg' })),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ afterEach(cleanup);
29
+
30
+ describe('MSG-6h: attachment gallery (thumbnail/lightbox/right-click download)', () => {
31
+ it('1. an image attachment renders a thumbnail; clicking it opens a lightbox showing the full image', async () => {
32
+ const api = makeApi();
33
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
34
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
35
+ await waitFor(() => expect(api.getAttachmentThumbnail).toHaveBeenCalledWith('att-1'));
36
+
37
+ fireEvent.click(thumbnail);
38
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
39
+ const lightboxImage = await screen.findByAltText('vacation-photo.jpg');
40
+ expect(lightboxImage.tagName).toBe('IMG');
41
+ });
42
+
43
+ it('2. right-clicking an image attachment opens a context menu with Download; selecting it downloads', async () => {
44
+ const api = makeApi();
45
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
46
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
47
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
48
+
49
+ fireEvent.contextMenu(thumbnail);
50
+ const downloadItem = await screen.findByText('MessagingAttachments.DOWNLOAD_ACTION');
51
+ fireEvent.click(downloadItem);
52
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
53
+ expect(clickSpy).toHaveBeenCalled();
54
+ clickSpy.mockRestore();
55
+ });
56
+
57
+ it('3. a non-image attachment renders a file indicator, not a broken image; clicking it downloads directly', async () => {
58
+ const api = makeApi();
59
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
60
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[fileAttachment]} /></MessagingProvider>);
61
+ const target = screen.getByLabelText('MessagingAttachments.DOWNLOAD:{"name":"Report.pdf"}');
62
+ expect(target.querySelector('img')).toBeNull();
63
+ expect(screen.getByText('Report.pdf')).toBeTruthy();
64
+
65
+ fireEvent.click(target);
66
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-2'));
67
+ expect(clickSpy).toHaveBeenCalled();
68
+ clickSpy.mockRestore();
69
+ // No lightbox for a non-image -- nothing to preview.
70
+ expect(screen.queryByRole('dialog')).toBeNull();
71
+ });
72
+
73
+ it('4. nameOf prefers attachment.filename when present, falls back to attachment.id when absent (pre-MSG-12 dcm interop)', () => {
74
+ render(<MessagingProvider api={makeApi()} active={false}><AttachmentList attachments={[fileAttachment, fallbackAttachment]} /></MessagingProvider>);
75
+ expect(screen.getByText('Report.pdf')).toBeTruthy();
76
+ expect(screen.getByText('att-3')).toBeTruthy();
77
+ });
78
+
79
+ it('6. the lightbox itself has a Download action -- reachable without a right-click, closing an accessibility regression (touch/keyboard users could not download an image otherwise)', async () => {
80
+ const api = makeApi();
81
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
82
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
83
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
84
+ fireEvent.click(thumbnail);
85
+ await screen.findByAltText('vacation-photo.jpg');
86
+ api.getAttachment.mockClear();
87
+
88
+ const downloadButton = screen.getByLabelText('MessagingAttachments.DOWNLOAD_ACTION');
89
+ fireEvent.click(downloadButton);
90
+ await waitFor(() => expect(api.getAttachment).toHaveBeenCalledWith('att-1'));
91
+ expect(clickSpy).toHaveBeenCalled();
92
+ clickSpy.mockRestore();
93
+ });
94
+
95
+ it('7. the lightbox shows a loading state while the full image fetches, and an error state if the fetch fails', async () => {
96
+ let resolveFetch;
97
+ const api = makeApi({ getAttachment: vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; })) });
98
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
99
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
100
+
101
+ fireEvent.click(thumbnail);
102
+ await screen.findByLabelText('MessagingAttachments.LOADING_PREVIEW');
103
+ expect(screen.queryByAltText('vacation-photo.jpg')).toBeNull();
104
+
105
+ resolveFetch(new Blob(['bytes'], { type: 'image/jpeg' }));
106
+ await screen.findByAltText('vacation-photo.jpg');
107
+ expect(screen.queryByLabelText('MessagingAttachments.LOADING_PREVIEW')).toBeNull();
108
+ });
109
+
110
+ it('8. a failed full-image fetch shows a visible error, not a silent no-op', async () => {
111
+ const api = makeApi({ getAttachment: vi.fn().mockRejectedValue(new Error('network error')) });
112
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[imageAttachment]} /></MessagingProvider>);
113
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
114
+
115
+ fireEvent.click(thumbnail);
116
+ await screen.findByRole('alert');
117
+ expect(screen.getByRole('alert').textContent).toBe('MessagingAttachments.PREVIEW_ERROR');
118
+ });
119
+
120
+ it('5. the attachment context menu does not also trigger the message-level action menu', async () => {
121
+ const api = makeApi();
122
+ render(
123
+ <AuthContext.Provider value={{ user: { id: 1 } }}>
124
+ <MessagingProvider api={api} active={false}>
125
+ <MessageBubble message={{ id: 'm1', body: 'see attached', sender: { id: 1 }, attachments: [imageAttachment] }} conversation={{ kind: 'group' }} canModerateMessages />
126
+ </MessagingProvider>
127
+ </AuthContext.Provider>,
128
+ );
129
+ const thumbnail = await screen.findByLabelText(/MessagingAttachments\.PREVIEW/);
130
+
131
+ fireEvent.contextMenu(thumbnail);
132
+ await screen.findByText('MessagingAttachments.DOWNLOAD_ACTION');
133
+ // The message-level menu's own items (e.g. Reply) must not also be open.
134
+ expect(screen.queryByText('MessagingThread.REPLY')).toBeNull();
135
+ });
136
+ });
@@ -0,0 +1,129 @@
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, within } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AttachmentList } from '../src/messaging/AttachmentList';
10
+ import { AuthContext } from '../src/auth/AuthContext';
11
+ import { Composer } from '../src/messaging/Composer';
12
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
13
+ import { PollCard } from '../src/messaging/PollCard';
14
+ import { QUICK_EMOJIS } from '../src/messaging/ReactionBar';
15
+ import { Thread } from '../src/messaging/Thread';
16
+
17
+ function makeApi(overrides = {}) {
18
+ return {
19
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
20
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
21
+ getAttachment: vi.fn().mockResolvedValue(new Blob(['bytes'], { type: 'image/jpeg' })),
22
+ getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' })),
23
+ createMessage: vi.fn(), uploadAttachments: vi.fn(),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ afterEach(cleanup);
29
+
30
+ describe('MSG-6i: attachment tile size, poll spacing/denominator, Composer emoji picker', () => {
31
+ it('1. an attachment tile renders at 120x120 (image and non-image)', async () => {
32
+ const image = { id: 'a1', filename: 'photo.jpg', content_type: 'image/jpeg' };
33
+ const file = { id: 'a2', filename: 'doc.pdf', content_type: 'application/pdf' };
34
+ render(<MessagingProvider api={makeApi()} active={false}><AttachmentList attachments={[image, file]} /></MessagingProvider>);
35
+ const tiles = await screen.findAllByRole('button');
36
+ const gallery = tiles.filter((tile) => tile.getAttribute('title') === 'photo.jpg' || tile.getAttribute('title') === 'doc.pdf');
37
+ expect(gallery).toHaveLength(2);
38
+ gallery.forEach((tile) => {
39
+ // MUI applies `sx={{ width, height }}` via a generated CSS class, not
40
+ // an inline style attribute -- read the resolved value instead.
41
+ expect(getComputedStyle(tile).width).toBe('120px');
42
+ expect(getComputedStyle(tile).height).toBe('120px');
43
+ });
44
+ });
45
+
46
+ it('2. poll percentage denominator is the count of distinct respondents, not sum(vote_count) -- must fail against the old sum(vote_count) code for a multi-select poll where one voter picked two options', () => {
47
+ // One respondent (voter-1) voted for both options. sum(vote_count) = 2,
48
+ // respondent count = 1. Old code: 1/2 = 50% each. New code: 1/1 = 100% each.
49
+ const poll = {
50
+ id: 'poll-1', question: 'Q', allow_multiple: true, closed_at: null,
51
+ options: [
52
+ { id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['voter-1'] },
53
+ { id: 'opt-b', text: 'B', order: 1, vote_count: 1, voters: ['voter-1'] },
54
+ ],
55
+ };
56
+ render(<MessagingProvider api={makeApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
57
+ const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
58
+ expect(results).toHaveLength(2);
59
+ results.forEach((node) => {
60
+ expect(node.textContent).toMatch(/"count":1/);
61
+ expect(node.textContent).toMatch(/"percent":100/);
62
+ });
63
+ });
64
+
65
+ it('3. poll percentage denominator: zero respondents still renders 0%, never NaN (no regression)', () => {
66
+ const poll = {
67
+ id: 'poll-2', question: 'Q', allow_multiple: false, closed_at: null,
68
+ options: [
69
+ { id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] },
70
+ { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] },
71
+ ],
72
+ };
73
+ render(<MessagingProvider api={makeApi()} active={false}><PollCard message={{ id: 'msg-2', poll }} /></MessagingProvider>);
74
+ const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
75
+ results.forEach((node) => {
76
+ expect(node.textContent).toMatch(/"count":0/);
77
+ expect(node.textContent).toMatch(/"percent":0/);
78
+ expect(node.textContent).not.toMatch(/NaN/);
79
+ });
80
+ });
81
+
82
+ it('4. the Composer emoji picker shows more than 5 options (the wider curated grid, not ReactionBar\'s QUICK_EMOJIS)', async () => {
83
+ const api = makeApi();
84
+ render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
85
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
86
+ const grid = await screen.findByRole('group', { name: 'MessagingComposer.EMOJI_PICKER' });
87
+ const emojiButtons = grid.querySelectorAll('button');
88
+ expect(emojiButtons.length).toBeGreaterThan(5);
89
+ });
90
+
91
+ it('4b. selecting an emoji from the Composer grid inserts it at the cursor position, not appended', async () => {
92
+ const api = makeApi();
93
+ render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
94
+ const input = screen.getByLabelText('MessagingComposer.MESSAGE');
95
+ fireEvent.change(input, { target: { value: 'abCD' } });
96
+ input.setSelectionRange(2, 2);
97
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
98
+ const grid = await screen.findByRole('group', { name: 'MessagingComposer.EMOJI_PICKER' });
99
+ fireEvent.click(within(grid).getByRole('button', { name: '😀' }));
100
+ await waitFor(() => expect(input.value).toBe('ab😀CD'));
101
+ });
102
+
103
+ it('5. ReactionBar\'s own QUICK_EMOJIS stays exactly 5, unaffected by the wider Composer picker', () => {
104
+ expect(QUICK_EMOJIS).toHaveLength(5);
105
+ });
106
+
107
+ it('6. Thread\'s root Stack and scroll Box carry flex:1/minHeight:0 (MSG-18 companion fix), so it can shrink inside a host\'s flex-column layout', async () => {
108
+ const api = makeApi({
109
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
110
+ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'hi' }], next_cursor: null }),
111
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
112
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: true }),
113
+ });
114
+ render(
115
+ <AuthContext.Provider value={{ user: { id: 9 } }}>
116
+ <MessagingProvider api={api}><Thread conversationId={1} /></MessagingProvider>
117
+ </AuthContext.Provider>,
118
+ );
119
+ const timeline = await screen.findByLabelText('MessagingThread.TIMELINE');
120
+ // The scroll Box is a direct child of Thread's root Stack.
121
+ const rootStack = timeline.parentElement;
122
+ [timeline, rootStack].forEach((el) => {
123
+ expect(getComputedStyle(el).flexGrow).toBe('1');
124
+ expect(getComputedStyle(el).minHeight).toBe('0px');
125
+ });
126
+ // maxHeight stays as the fallback cap for a host that doesn't flex-wrap Thread.
127
+ expect(getComputedStyle(timeline).maxHeight).toBe('560px');
128
+ });
129
+ });
@@ -0,0 +1,238 @@
1
+ # WORK ORDER MSG-6h (ui-core-micha) — attachment gallery (thumbnail/lightbox/right-click download), reaction overlap
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
4
+ addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
5
+ out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
6
+ via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
7
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
8
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
9
+
10
+ Feature-level WO (Expertenchat envelope). Operator report 2026-08-03 from a staging screenshot, seen
11
+ now for the first time end-to-end because `django-core-micha` `MSG-10` just fixed attachment uploads
12
+ (they were unconditionally rejected before — this display code has effectively never been exercised
13
+ against a real upload on the platform path).
14
+
15
+ ## TIER
16
+ Tier 2 — shared-core UI consumed by every app. Independent `reviewer` + `ui_reviewer` mandatory.
17
+
18
+ ## SCOPE
19
+
20
+ **A. Attachment gallery.**
21
+
22
+ Current state (`AttachmentList.jsx`): each attachment renders as a `Button` containing a 36×36px
23
+ thumbnail (if the file is an image and its thumbnail loaded) plus a text label
24
+ `t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })`. `nameOf` is
25
+ `attachment.filename || attachment.name || attachment.id` — since dcm's `serialize_attachment` has
26
+ never returned `filename` (see `django-core-micha` `work-orders/MSG-12.md`, its companion, fixing
27
+ that), this **always** fell through to the raw UUID `id`. Clicking anywhere on the button downloads
28
+ the file — there is no preview step.
29
+
30
+ Operator's target shape, verbatim: *"bitte kleines Vorschaubild, bei Klick darauf grosse Vorschau,
31
+ rechtsklick zum Kontextmenu mit Download (analog Whatsapp)"* — a small thumbnail; clicking it opens a
32
+ large preview (lightbox); right-click opens a context menu with a download action.
33
+
34
+ Build this:
35
+ - **Image attachments**: small thumbnail (reuse the existing `getAttachmentThumbnail` fetch — already
36
+ wired). Click → a lightbox (MUI `Dialog` or `Modal`) showing the full-size image, fetched via the
37
+ existing `getAttachment` blob endpoint. Right-click (`onContextMenu`) → a small context menu (MUI
38
+ `Menu`, matching the pattern already used for message actions in `MessageBubble.jsx`) with a
39
+ Download action, calling the same download logic `AttachmentList` already has.
40
+ - **Non-image attachments** (no thumbnail available): keep a compact file-type indicator (icon +
41
+ filename, once `MSG-12` supplies a real one) as the tap target; single click downloads directly
42
+ (there is nothing to preview) — right-click still offers the same context menu for consistency, even
43
+ though its one item duplicates the click action.
44
+ - Filename: once `MSG-12` ships, `nameOf` should read the real `filename` first; keep the existing
45
+ `|| attachment.id` fallback for interop with a not-yet-bumped dcm pin (an older host may still be on
46
+ a dcm version that omits it) — never let a missing filename break rendering, just fall back visibly.
47
+ - Keep the download-only design principle: bytes never touch anything but the existing authenticated
48
+ `getAttachment`/`getAttachmentThumbnail` blob fetches already in `MessagingProvider`. No new
49
+ endpoints, no new byte-handling path.
50
+
51
+ **B. Reaction bar overlap.**
52
+
53
+ `MessageBubble.jsx:139` renders `ReactionBar` as a sibling *after* the bubble's `Paper`, with
54
+ `sx={{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }}` — a small negative top margin
55
+ deliberately pulls it up to overlap the bubble's bottom edge (a common "chip peeking out" treatment).
56
+ Operator report: this overlap now reads as broken/unnatural, screenshotted on a message that has
57
+ attachments — worth checking specifically whether the fixed `-0.5` offset was tuned against a
58
+ plain-text bubble and no longer sits right against the bottom of an attachment row or (from `MSG-6g`)
59
+ a poll's result bars, both of which changed the bubble's actual bottom content since this offset was
60
+ last touched.
61
+
62
+ **Live-verify against the operator's original screenshot before considering this closed** — the
63
+ Orchestrator does not have staging credentials this round; if a local repro isn't conclusive, say so
64
+ and ask the operator to confirm the fix visually rather than guessing further.
65
+
66
+ ## NON-GOALS / DO NOT TOUCH
67
+ - Do not touch `PollCard.jsx`, `ReadTicks.jsx`, or conversation titles — unrelated recent WOs.
68
+ - Do not add any new backend endpoint — scope A only consumes what already exists (plus `MSG-12`'s
69
+ additive `filename` field).
70
+ - Do not change the reaction *toggle* logic (`ReactionBar.jsx`'s `react`/`pending` mechanics) — only
71
+ its positioning relative to the bubble, scope B.
72
+ - Do not implement a generic lightbox/gallery component library dependency — build directly with MUI
73
+ primitives already used elsewhere in this codebase (`Dialog`, `Menu`), matching existing patterns.
74
+
75
+ ## RISKS
76
+ - A right-click context menu on a message bubble already exists for message-level actions
77
+ (`MessageBubble.jsx`'s `openMenu`/`canOpenMenu`, triggered by `onContextMenu`). An attachment's own
78
+ right-click context menu must not fight with or accidentally trigger the message-level one when the
79
+ attachment sits inside the bubble — `event.stopPropagation()` where appropriate.
80
+ - Lightbox must be keyboard-dismissable (Escape) and trap focus reasonably — it is a modal.
81
+ - The thumbnail fetch already exists and works (`AttachmentList.jsx`'s current `useEffect`); reuse it,
82
+ don't re-invent — but the full-size lightbox fetch (`getAttachment`) is a new call path, verify it
83
+ doesn't double-fetch what the thumbnail effect already has cached.
84
+
85
+ ## REQUIRED TESTS TO WRITE
86
+ Narrow and behavioural. Do NOT run the full suite.
87
+
88
+ 1. An image attachment renders a thumbnail; clicking it opens a lightbox showing the full image
89
+ (fetched via `getAttachment`).
90
+ 2. Right-clicking an image attachment opens a context menu with a Download action; selecting it
91
+ downloads (same assertion shape as the existing pre-redesign download test, if one exists — check).
92
+ 3. A non-image attachment (no `thumbnail_url`) renders a file indicator, not a broken image; clicking
93
+ it downloads directly.
94
+ 4. `nameOf` prefers `attachment.filename` when present, falls back to `attachment.id` when absent
95
+ (interop with a dcm pin that predates `MSG-12`) — assert both cases render without crashing.
96
+ 5. The attachment's own context menu does not also trigger the message-level action menu (no
97
+ double-open, no propagation leak) — regression guard given `MessageBubble.jsx` already has its own
98
+ `onContextMenu`.
99
+
100
+ ## TEST SCOPE FOR THE GATE (orchestrator)
101
+ Attachment- and reaction-bar-adjacent messaging test files. Not the full suite.
102
+
103
+ ## TARGET REPO
104
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release flow;
105
+ consuming pin bumps (jg-ferien, and `django-core-micha` `MSG-12` for the filename) are separate,
106
+ subsequent steps — **but per standing feedback this session, do not report this WO as "done" until
107
+ those consuming steps have actually landed**, not merely published.
108
+
109
+ ## MINI-HANDOVER (pastable)
110
+
111
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`). Work order:
112
+ > `work-orders/MSG-6h.md` — read it fully, then follow the `orchestrate-codex` skill.
113
+ >
114
+ > Two independent scopes, both operator-reported live on staging: (A) `AttachmentList.jsx` needs a
115
+ > real thumbnail-click-lightbox / right-click-download-menu gallery, WhatsApp-style, instead of a bare
116
+ > download button showing a raw UUID (the UUID-fallback root cause is fixed separately in
117
+ > `django-core-micha` `MSG-12`, a companion WO — consume its new `filename` field with a graceful
118
+ > fallback for an older pin). (B) `MessageBubble.jsx:139`'s `ReactionBar` has a fixed `mt: -0.5`
119
+ > negative-margin overlap trick that now reads as visually broken against certain bubble content
120
+ > (attachments, and possibly `MSG-6g`'s poll bars) — investigate and fix, but flag if it needs a live
121
+ > staging screenshot to confirm rather than guessing blind.
122
+
123
+ ## PROGRESS CONTRACT
124
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
125
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
126
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
127
+
128
+ ---
129
+
130
+ ## Part B — Implementation map (Orchestrator) / as-built record
131
+
132
+ **Codex was unavailable (out of credits) — implemented directly by the Orchestrator.**
133
+
134
+ ### Scope A — `src/messaging/AttachmentList.jsx` (full rewrite)
135
+
136
+ - Each attachment is now a 64×64 `ButtonBase` tile (not a full-width text button): an image
137
+ attachment shows its existing thumbnail fetch (unchanged `getAttachmentThumbnail` `useEffect`,
138
+ reused verbatim); a non-image attachment shows a generic file icon +
139
+ filename (`InsertDriveFileOutlinedIcon`).
140
+ - Click: image → `openLightbox` (fetches the full-resolution blob via the existing `getAttachment`,
141
+ cached per-attachment in a `fullUrlCacheRef` so a re-open within the same mount doesn't re-fetch);
142
+ non-image → `download` (unchanged logic, just relocated).
143
+ - Right-click (`onContextMenu`): opens a MUI `Menu` (anchored to click position) with one Download
144
+ item, for both image and non-image attachments. `event.stopPropagation()` inside the handler stops
145
+ it from also reaching `MessageBubble.jsx`'s own bubble-level `onContextMenu` (verified with a
146
+ dedicated regression test, item 5 below).
147
+ - Lightbox: a MUI `Dialog` with the full image + a close `IconButton` (also closes via the Dialog's
148
+ own backdrop-click/Escape handling, native to MUI).
149
+ - `nameOf(attachment)` unchanged in shape (`filename || name || id`), now actually resolves to a real
150
+ name once the host's dcm pin includes `MSG-12`; falls back to `id` for an older pin (test 4).
151
+ - 3 new i18n keys: `MessagingAttachments.PREVIEW`, `.CLOSE_PREVIEW`, `.DOWNLOAD_ACTION`. Existing
152
+ `.LABEL`/`.DOWNLOAD` kept — `.DOWNLOAD` is now also the accessible name for a non-image tile (no
153
+ visible "Download X" text on it any more, per the new compact-tile design — updated one pre-existing
154
+ test in `messagingCompactBubble.test.jsx` that asserted on the old visible text).
155
+
156
+ ### Scope B — `src/messaging/MessageBubble.jsx:139`
157
+
158
+ - `ReactionBar`'s `sx` changed from `{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }` to
159
+ `{ mt: 0.5, ml: 0.75 }` — a small **positive** gap instead of the negative-margin "peek from the
160
+ bubble's bottom edge" trick. That trick pulled the chip up into whatever sits immediately above it
161
+ in normal document flow, which used to always be plain text or the compact meta row; since MSG-6g
162
+ added poll bars and this WO's own gallery redesign changed the attachment row's height/borders, the
163
+ fixed offset started visually cutting into bordered, boxier content instead of text. Also dropped
164
+ `position: relative` / `zIndex: 1`, no longer needed without the overlap.
165
+ - **Not independently visually re-verified against the operator's original staging screenshot** — no
166
+ staging credentials this round (same limitation noted when the ucm pin bump was last reported). The
167
+ fix is a conservative, low-risk "stop overlapping anything" change; flagging per the WO's own
168
+ instruction to say so rather than claim a live-confirmed fix.
169
+
170
+ ### Tests
171
+ `tests/messagingMsg6h.test.jsx` (new) — the 5 WO-required cases. `tests/messagingCompactBubble.test.jsx`
172
+ updated (pre-existing test asserted on now-removed visible DOWNLOAD text). No other existing test
173
+ needed a change (`messagingComposer.test.jsx`'s AttachmentList smoke test only checked the outer
174
+ `aria-label`, unaffected).
175
+
176
+ ### Verification (pre-review)
177
+ Full suite 241 → 246 (5 new), `tsc` clean.
178
+
179
+ ### Independent review: `reviewer` + `ui_reviewer`, both mandatory (author = Orchestrator, not Codex)
180
+
181
+ Both reviews converged on the same core gap from different angles — fixed together in one pass.
182
+
183
+ **`reviewer` — 3 findings:**
184
+ - **R1 (P2, real regression):** the pre-diff design let every attachment (image or not) download via a
185
+ direct click/Enter/Space on a plain `Button`. The redesign made an image's click open the lightbox
186
+ instead, which had only a Close button — the *only* way left to download an image was a real mouse
187
+ right-click, unreachable by keyboard or (with no long-press) touch. **Fixed**: the lightbox now has
188
+ its own Download `IconButton`, so download is reachable via click, Tab+Enter, or touch, without ever
189
+ needing the context menu.
190
+ - **R2 (P2):** the new lightbox fetch had no loading or error state — a click gave no feedback until
191
+ the network round trip resolved, and a failure silently no-opped. **Fixed**: `lightbox.status`
192
+ (`'loading' | 'ready' | 'error'`) drives a `CircularProgress` while fetching and an `Alert` on
193
+ failure; a stale in-flight fetch (opened, then a *different* attachment opened before the first
194
+ resolves) is guarded by comparing the resolved attachment's id against the current lightbox state
195
+ before applying the result.
196
+ - **R3 (P3):** the lightbox `Dialog` had no accessible name. **Fixed**: `aria-label` on the `Dialog`
197
+ itself, set to the previewed attachment's name.
198
+
199
+ Verified correct, no bug: object-URL lifecycle (no leak/double-revoke/revoke-while-displayed), the
200
+ context-menu `stopPropagation` (traced against React's synthetic event dispatch + the dedicated test),
201
+ no stale-closure risk in the menu's download handler, `fullUrlCacheRef` can't go stale across messages
202
+ (`Thread.jsx` keys `MessageBubble` by `message.id`, always unmounts on swap), i18n coverage.
203
+
204
+ **`ui_reviewer` — 6 findings:**
205
+ - **U1 (P1, same root cause as R1):** touch users had no way to download an image at all (no
206
+ long-press implemented, lightbox had no download control). **Fixed by the same lightbox Download
207
+ button as R1** — one fix closes both findings.
208
+ - **U2 (P2):** no visual affordance signals a tile is right-clickable, and right-click-for-download is
209
+ a non-obvious pattern without a fallback for a mouse user who doesn't know it. **Resolved by the R1/U1
210
+ fix, not a separate change**: right-click is no longer the *only* path to anything — an image is
211
+ fully reachable via primary click → lightbox → visible Download button, and a non-image already
212
+ downloads on primary click. Right-click remains a discoverable-if-you-know-it shortcut, not a
213
+ required, hidden one; adding a second, redundant hover-reveal affordance on top of an already-fully-
214
+ reachable primary interaction would be scope creep past what the finding's own root concern requires.
215
+ - **U3 (P2):** a non-image tile's filename truncates in a 64px box with no way to recover the full name
216
+ before downloading. **Fixed**: `title={nameOf(attachment)}` on the tile (native browser tooltip on
217
+ hover).
218
+ - **U4 (P3):** the French `PREVIEW` translation (`'Aperçu de {{name}}'`, noun-first) broke grammatical
219
+ parallelism with the sibling `DOWNLOAD`/`DOWNLOAD_ACTION` (verb-first). **Fixed**: `'Prévisualiser
220
+ {{name}}'`.
221
+ - **U5 (P3):** the lightbox close button omitted the `boxShadow` the codebase's analogous floating-icon
222
+ pattern (`MessageBubble.jsx`'s hover-revealed `MoreVertIcon`) uses for contrast. **Fixed**: both the
223
+ new Download button and the Close button now carry `boxShadow: 1`.
224
+ - **U6 (P3, not fixed, disclosed):** `openLightbox`'s failure path is a silent no-op if `getAttachment`
225
+ is bypassed some other way — the reviewer themselves noted this mirrors `download()`'s own
226
+ pre-existing silent-failure shape, not a new defect class. Left as-is, consistent with that existing
227
+ convention elsewhere in this component; R2's loading/error state above already covers the actual
228
+ reachable failure path (a real fetch rejection).
229
+
230
+ ### Not independently visually re-verified
231
+ Scope B's `ReactionBar` spacing fix (`MessageBubble.jsx:145`) was not confirmed against the operator's
232
+ original staging screenshot — no staging credentials this round. Both reviewers examined it by static
233
+ code reading only and found the fix low-risk (a strict `mt` sign flip, no z-stacking or other side
234
+ effect traced), but this is a judgment call, not a live-rendered confirmation. Flagging per the WO's
235
+ own instruction rather than claiming a visual fix I have not actually seen.
236
+
237
+ ### Re-verification after all fixes
238
+ Full suite 241 → 249 (8 new: 5 original + 3 closing R1/R2), `tsc` clean.