@micha.bigler/ui-core-micha 2.16.0 → 2.17.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.
Files changed (38) hide show
  1. package/WORK_ORDERS.md +2 -1
  2. package/dev/entries.jsx +4 -0
  3. package/dist/i18n/messagingTranslations.js +26 -0
  4. package/dist/index.js +1 -0
  5. package/dist/messaging/Composer.js +41 -5
  6. package/dist/messaging/ConversationList.js +10 -3
  7. package/dist/messaging/DirectMessageLauncher.js +48 -0
  8. package/dist/messaging/MessageBubble.js +84 -4
  9. package/dist/messaging/MessagingProvider.js +62 -16
  10. package/dist/messaging/ReactionBar.js +1 -1
  11. package/dist/messaging/ReadTicks.js +7 -4
  12. package/dist/messaging/Thread.js +45 -8
  13. package/dist/messaging/api.js +3 -3
  14. package/dist/messaging/composerImageCompression.js +36 -0
  15. package/docs/messaging-deviations.md +91 -26
  16. package/package.json +1 -1
  17. package/src/i18n/messagingTranslations.ts +26 -0
  18. package/src/index.js +1 -0
  19. package/src/messaging/Composer.jsx +54 -8
  20. package/src/messaging/ConversationList.jsx +10 -4
  21. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  22. package/src/messaging/MessageBubble.jsx +74 -7
  23. package/src/messaging/MessagingProvider.jsx +83 -11
  24. package/src/messaging/ReactionBar.jsx +1 -1
  25. package/src/messaging/ReadTicks.jsx +5 -3
  26. package/src/messaging/Thread.jsx +39 -8
  27. package/src/messaging/api.js +3 -2
  28. package/src/messaging/composerImageCompression.js +35 -0
  29. package/tests/Thread.test.jsx +1 -1
  30. package/tests/messagingChunk6.test.jsx +109 -0
  31. package/tests/messagingComposerChunk5.test.jsx +110 -0
  32. package/tests/messagingContractConformance.test.js +42 -0
  33. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  34. package/tests/messagingExports.test.js +2 -1
  35. package/tests/messagingMessageActions.test.jsx +93 -0
  36. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  37. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  38. package/work-orders/MSG-3b.md +1002 -0
@@ -0,0 +1,86 @@
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?.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 { applyFrame, MessagingProvider } from '../src/messaging/MessagingProvider';
12
+ import { Thread } from '../src/messaging/Thread';
13
+
14
+ function makeApi(messages = []) {
15
+ return {
16
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
17
+ listMessages: vi.fn().mockResolvedValue({ results: messages }),
18
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
19
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
20
+ markConversationRead: vi.fn().mockResolvedValue({}),
21
+ markThreadRead: vi.fn().mockResolvedValue({}),
22
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 0 }),
23
+ };
24
+ }
25
+ function renderBubble(ui) {
26
+ return render(<MessagingProvider api={makeApi()} active={false}>{ui}</MessagingProvider>);
27
+ }
28
+
29
+ afterEach(cleanup);
30
+
31
+ describe('messaging threading and quoting', () => {
32
+ it('groups REST-loaded server-shaped reply_to_id messages under their root', async () => {
33
+ const api = makeApi([
34
+ { id: 10, conversation_id: 1, body: 'Root', sender: { display_name: 'Ava' }, created_at: '2026-07-31T10:00:00Z' },
35
+ { id: 11, conversation_id: 1, body: 'Server reply', reply_to_id: 10, sender: { display_name: 'Ben' }, created_at: '2026-07-31T10:01:00Z' },
36
+ ]);
37
+ render(<MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider>);
38
+ await screen.findByText('Root');
39
+ expect(screen.queryByText('Server reply')).toBeNull();
40
+ fireEvent.click(screen.getByRole('button', { name: /MessagingThread\.SHOW_REPLIES/ }));
41
+ expect(await screen.findByText('Server reply')).toBeTruthy();
42
+ });
43
+
44
+ it('groups realtime server-shaped reply_to_id messages under their root', () => {
45
+ const state = { conversations: {}, messages: { 10: { id: 10, conversation_id: 1, body: 'Root' } }, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
46
+ const next = applyFrame(state, { type: 'message', conversation_id: 1, message: { id: 11, body: 'Realtime reply', reply_to_id: 10 } }, 1);
47
+ expect(next.messages[11].reply_to_id).toBe(10);
48
+ expect(Object.values(next.messages).filter((message) => !((message.reply_to_id ?? message.reply_to))).map((message) => message.id)).toEqual([10]);
49
+ });
50
+
51
+ it('renders a quoted sender and snippet, blanking deleted quoted content', () => {
52
+ const { rerender } = renderBubble(<MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, body: 'Quoted words', sender: { display_name: 'Ava' } }} />);
53
+ expect(screen.getByText('MessagingThread.REPLY_TO:Ava')).toBeTruthy();
54
+ expect(screen.getByText('Quoted words')).toBeTruthy();
55
+ rerender(<MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, body: 'Secret', deleted_at: '2026-07-31T10:00:00Z', sender: { display_name: 'Ava' } }} /></MessagingProvider>);
56
+ expect(screen.getByText('MessagingThread.DELETED')).toBeTruthy();
57
+ expect(screen.queryByText('Secret')).toBeNull();
58
+ });
59
+
60
+ it('labels an attachment-only quoted source distinctly from a deleted one', () => {
61
+ renderBubble(<MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, attachments: [{ id: 9, filename: 'photo.png' }], sender: { display_name: 'Ava' } }} />);
62
+ expect(screen.getByText('MessagingThread.QUOTE_ATTACHMENT')).toBeTruthy();
63
+ expect(screen.queryByText('MessagingThread.DELETED')).toBeNull();
64
+ });
65
+
66
+ it("clears the Thread's own reply-target state when the message being replied to is deleted", async () => {
67
+ const api = makeApi([{ id: 20, conversation_id: 1, body: 'Reply target', sender: { id: 1, display_name: 'Ava' }, created_at: '2026-07-31T10:00:00Z' }]);
68
+ api.deleteMessage = vi.fn().mockResolvedValue({});
69
+ render(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider></AuthContext.Provider>);
70
+ await screen.findByText('Reply target');
71
+ fireEvent.click(screen.getByRole('button', { name: /MessagingThread\.REPLY\b/ }));
72
+ expect(screen.getByText(/MessagingThread\.REPLYING_TO/)).toBeTruthy();
73
+ fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
74
+ fireEvent.click(screen.getByRole('menuitem', { name: /MessagingActions\.DELETE/ }));
75
+ fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.DELETE/ }));
76
+ await waitFor(() => expect(api.deleteMessage).toHaveBeenCalledWith(20));
77
+ await waitFor(() => expect(screen.queryByText(/MessagingThread\.REPLYING_TO/)).toBeNull());
78
+ });
79
+
80
+ it('jumps from a quote to the original message', () => {
81
+ const onJumpToMessage = vi.fn();
82
+ renderBubble(<MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, body: 'Quoted words', sender: { display_name: 'Ava' } }} onJumpToMessage={onJumpToMessage} />);
83
+ fireEvent.click(screen.getByText('Quoted words'));
84
+ expect(onJumpToMessage).toHaveBeenCalledWith(1);
85
+ });
86
+ });
@@ -0,0 +1,63 @@
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) => key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { ConversationList } from '../src/messaging/ConversationList';
10
+ import { messagingReducer, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
11
+ import { Thread } from '../src/messaging/Thread';
12
+
13
+ function cache() { return { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } }; }
14
+ function State() { const { cache: current } = useMessaging(); return <output data-testid="cache">{JSON.stringify(current)}</output>; }
15
+ function makeApi(overrides = {}) {
16
+ return {
17
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), listThread: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
18
+ markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn().mockResolvedValue({}),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ afterEach(cleanup);
24
+
25
+ describe('messaging unread lifecycle', () => {
26
+ it('marks the selected conversation read', async () => {
27
+ const api = makeApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 12, title: 'Support' }] }) });
28
+ render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
29
+ fireEvent.click(await screen.findByRole('button', { name: /Support/ }));
30
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledWith(12, undefined));
31
+ });
32
+
33
+ it('marks a conversation read when its thread opens', async () => {
34
+ const api = makeApi();
35
+ render(<MessagingProvider api={api} active={false}><Thread conversationId={12} /></MessagingProvider>);
36
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledWith(12, undefined));
37
+ });
38
+
39
+ it('decrements the global unread count by the selected conversation prior count', () => {
40
+ const initial = { ...cache(), unread: { unread_count: 5, by_conversation: { 12: 2, 7: 3 } } };
41
+ const next = messagingReducer(initial, { type: 'conversationRead', conversationId: 12 });
42
+ expect(next.unread).toEqual({ unread_count: 3, by_conversation: { 12: 0, 7: 3 } });
43
+ });
44
+
45
+ it('marks a reply thread read when it expands', async () => {
46
+ const api = makeApi({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 40, conversation_id: 12, body: 'Root', reply_count: 1 }] }) });
47
+ render(<MessagingProvider api={api} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>);
48
+ fireEvent.click(await screen.findByRole('button', { name: 'MessagingThread.SHOW_REPLIES' }));
49
+ await waitFor(() => expect(api.markThreadRead).toHaveBeenCalledWith(40, undefined));
50
+ });
51
+
52
+ it('increments unread only for a new message in a non-active conversation', () => {
53
+ const initial = { ...cache(), unread: { unread_count: 2, by_conversation: { 7: 2 } } };
54
+ const incoming = { type: 'frame', activeConversationId: 7, frame: { type: 'message', conversation_id: 12, message: { id: 50, body: 'hello' } } };
55
+ const nonActive = messagingReducer(initial, incoming);
56
+ expect(nonActive.unread).toEqual({ unread_count: 3, by_conversation: { 7: 2, 12: 1 } });
57
+ const active = messagingReducer(nonActive, { ...incoming, frame: { type: 'message', conversation_id: 7, message: { id: 51, body: 'active' } } });
58
+ expect(active.unread).toEqual(nonActive.unread);
59
+ const optimistic = messagingReducer(initial, { type: 'messageOptimistic', message: { id: 'local-r1', conversation_id: 12, client_request_id: 'r1', status: 'pending' } });
60
+ const reconciled = messagingReducer(optimistic, { ...incoming, frame: { type: 'message', conversation_id: 12, message: { id: 52, client_request_id: 'r1', body: 'mine' } } });
61
+ expect(reconciled.unread).toEqual(initial.unread);
62
+ });
63
+ });