mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): chat, note and poll cards drawn like the Collab tab
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* FE-AI-COLLAB-001 to FE-AI-COLLAB-033
|
||||
*
|
||||
* The trip chat's cards as the assistant chat draws them: messages as the
|
||||
* chat draws them (sides, faces, names, quotes, reactions, a held send, a
|
||||
* removal), polls as the Collab tab draws them (results only after a vote or
|
||||
* the end, the winner, a held vote), shared notes with their colour, facts
|
||||
* and changes, the phone's sizes, and the pure look and model behind them.
|
||||
*/
|
||||
import type {
|
||||
AssistantMessage,
|
||||
CollabBubbleCard,
|
||||
CollabChatCard,
|
||||
CollabNoteCard,
|
||||
CollabNotesCard,
|
||||
CollabPollCard,
|
||||
CollabPollsCard,
|
||||
TrekCard,
|
||||
} from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { formatChatClockTime } from '../../../Collab/collabModel';
|
||||
import type { ChangeItem, ChangeState } from '../../assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import { CARD_LOOKS, CARD_RULES, ENTITY_LOOK, STEP_LOOKS, TOOL_LABELS } from '../cardLooks';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { bubbleStatus, mergedChat, pollOutcome, shareOf, spotOf } from './collabCardModel';
|
||||
import CollabChatBlock, { CollabChatBody } from './CollabChatCard';
|
||||
import { CHAT_TARGET, NOTES_TARGET, POLLS_TARGET, readServedChange } from './collabLook';
|
||||
import CollabNotesBlock, { CollabNoteFacts, CollabNotesBody } from './CollabNotesCard';
|
||||
import CollabPollsBlock, { CollabPollsBody } from './CollabPollsCard';
|
||||
|
||||
const AUTH = useAuthStore.getState();
|
||||
const MESSAGE = { id: 'm1', role: 'assistant', parts: [] } as unknown as AssistantMessage;
|
||||
const ANNA = { id: 7, name: 'Anna', avatarKey: 'anna.png' };
|
||||
const JON = { id: 8, name: 'Jon', avatarKey: null };
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: buildUser({ id: 7 }) });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', density: 'compact', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
const entries = (...cards: TrekCard[]): CardEntry[] =>
|
||||
cards.map((card, index) => ({ toolCallId: `call_${index}`, card }));
|
||||
|
||||
/** A change row holding `card`, decided as `state`, with the change's own rows. */
|
||||
const itemOf = (card: TrekCard, state: ChangeState, rows: unknown[] = []): ChangeItem =>
|
||||
({
|
||||
toolCallId: 'call_x',
|
||||
preview: {
|
||||
toolCallId: 'call_x',
|
||||
verbKey: 'genericUpdate',
|
||||
subject: 'x',
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
},
|
||||
applied: null,
|
||||
state,
|
||||
card,
|
||||
}) as unknown as ChangeItem;
|
||||
|
||||
const bubble = (over: Partial<CollabBubbleCard> = {}): CollabBubbleCard => ({
|
||||
id: 1,
|
||||
author: JON,
|
||||
text: 'Shall we take the early train?',
|
||||
cut: false,
|
||||
at: '2026-10-21T09:12:00Z',
|
||||
reply: null,
|
||||
reactions: [],
|
||||
files: [],
|
||||
fromAssistant: false,
|
||||
change: 'none',
|
||||
reactEmoji: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const chat = (messages: CollabBubbleCard[], earlier = 0): CollabChatCard => ({ kind: 'collabChat', messages, earlier });
|
||||
|
||||
const poll = (over: Partial<CollabPollCard> = {}): CollabPollCard => ({
|
||||
pollId: 5,
|
||||
question: 'Where do we eat on Friday?',
|
||||
options: [
|
||||
{ text: 'Trattoria Roma', votes: 2, mine: false, voters: [ANNA, JON] },
|
||||
{ text: 'Night market', votes: 1, mine: false, voters: [JON] },
|
||||
],
|
||||
moreOptions: 0,
|
||||
multiple: false,
|
||||
closed: false,
|
||||
deadline: null,
|
||||
author: JON,
|
||||
change: 'none',
|
||||
votedIndex: null,
|
||||
votedAdds: false,
|
||||
warning: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const polls = (...list: CollabPollCard[]): CollabPollsCard => ({ kind: 'collabPolls', polls: list, more: 0 });
|
||||
|
||||
const note = (over: Partial<CollabNoteCard> = {}): CollabNoteCard => ({
|
||||
noteId: 9,
|
||||
title: 'Restaurants to try',
|
||||
body: 'Pasta at **Luigi** and https://luigis.example/menu',
|
||||
category: 'Ideas',
|
||||
color: '#10b981',
|
||||
pinned: false,
|
||||
websiteHost: 'luigis.example',
|
||||
files: 2,
|
||||
author: ANNA,
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const notes = (...list: CollabNoteCard[]): CollabNotesCard => ({ kind: 'collabNotes', notes: list, more: 0 });
|
||||
|
||||
describe('the chat card', () => {
|
||||
it('FE-AI-COLLAB-001: puts the others on one side with face and name, the user on the other in the accent', () => {
|
||||
const thread = chat([bubble(), bubble({ id: 2, author: ANNA, text: 'Yes, 7:40 from the main station.' })]);
|
||||
inView(<CollabChatBlock cards={entries(thread)} message={MESSAGE} />);
|
||||
const other = screen.getByText('Shall we take the early train?').closest('div[style]');
|
||||
const own = screen.getByText('Yes, 7:40 from the main station.').closest('div[style]');
|
||||
expect(other?.getAttribute('style')).toContain('background: var(--bg-card)');
|
||||
expect(own?.getAttribute('style')).toContain('background: var(--accent)');
|
||||
expect(screen.getByText('Jon')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Anna')).toBeNull();
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-002: quotes a reply, names who reacted, keeps links as words and draws emoji alone large', () => {
|
||||
const thread = chat([
|
||||
bubble({
|
||||
reply: { name: 'Anna', text: 'Train or bus?' },
|
||||
text: 'See https://rail.example/times',
|
||||
reactions: [{ emoji: '❤️', count: 2, mine: true, names: ['Anna', 'Jon'] }],
|
||||
}),
|
||||
bubble({ id: 2, text: '🎉' }),
|
||||
]);
|
||||
const { container } = inView(<CollabChatBlock cards={entries(thread)} message={MESSAGE} />);
|
||||
expect(screen.getByText('Train or bus?')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://rail.example/times').tagName).toBe('SPAN');
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
expect(screen.getByLabelText('❤️ 2: Anna, Jon')).toBeInTheDocument();
|
||||
expect(screen.getByText('🎉').getAttribute('style')).toContain('font-size: calc(40px');
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-003: merges the reads of one answer, each message once in order, counts the older ones and opens the chat', () => {
|
||||
const onOpen = vi.fn();
|
||||
const first = chat([bubble({ id: 2, at: '2026-10-21T09:13:00Z', text: 'second' }), bubble({ text: 'first' })], 3);
|
||||
const second = chat([bubble({ id: 2, at: '2026-10-21T09:13:00Z', text: 'second' })], 1);
|
||||
inView(<CollabChatBlock cards={entries(first, second)} message={MESSAGE} />, { onOpen });
|
||||
expect(screen.getAllByText('second')).toHaveLength(1);
|
||||
const texts = [...document.querySelectorAll('div[style]')].map((el) => el.textContent);
|
||||
expect(texts.indexOf('first')).toBeLessThan(texts.indexOf('second'));
|
||||
expect(screen.getByText('3 earlier messages')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open the chat/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith(CHAT_TARGET);
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-004: a held send says it is not sent yet, then its time and sent, or not sent', () => {
|
||||
const held = chat([bubble({ id: null, at: null, author: ANNA, text: 'Booked!', change: 'send' })]);
|
||||
const { rerender } = inView(<CollabChatBody card={held} item={itemOf(held, 'pending')} />);
|
||||
expect(screen.getByText('Not sent yet')).toBeInTheDocument();
|
||||
const sent = chat([bubble({ id: 40, author: ANNA, text: 'Booked!', change: 'send' })]);
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabChatBody card={sent} item={itemOf(sent, 'applied')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByText(formatChatClockTime('2026-10-21T09:12:00Z', false))).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Sent' })).toBeInTheDocument();
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabChatBody card={held} item={itemOf(held, 'failed')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByText('Not sent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-005: a removal strikes the message, and once applied reads as the chat placeholder under its day', () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-21T12:00:00Z'), toFake: ['Date'] });
|
||||
const removing = chat([bubble({ author: ANNA, text: 'Wrong train', change: 'remove' })]);
|
||||
const { rerender } = inView(<CollabChatBody card={removing} item={itemOf(removing, 'pending')} />);
|
||||
expect(screen.getByText('Wrong train').closest('.line-through')).not.toBeNull();
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabChatBody card={removing} item={itemOf(removing, 'applied')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.queryByText('Wrong train')).toBeNull();
|
||||
expect(screen.getByText('You deleted a message')).toBeInTheDocument();
|
||||
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-006: a held reaction rings the emoji it toggles until it is applied', () => {
|
||||
const reacting = chat([
|
||||
bubble({
|
||||
change: 'react',
|
||||
reactEmoji: '👍',
|
||||
reactions: [
|
||||
{ emoji: '👍', count: 1, mine: true, names: ['Anna'] },
|
||||
{ emoji: '❤️', count: 1, mine: false, names: ['Jon'] },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const { rerender } = inView(<CollabChatBody card={reacting} item={itemOf(reacting, 'pending')} />);
|
||||
expect(screen.getByLabelText('👍 1: Anna').className).toContain('border-dashed');
|
||||
expect(screen.getByLabelText('❤️ 1: Jon').className).not.toContain('border-dashed');
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabChatBody card={reacting} item={itemOf(reacting, 'applied')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByLabelText('👍 1: Anna').className).not.toContain('border-dashed');
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-033: a held reaction that takes away the last of its emoji keeps it faint until applied', () => {
|
||||
const takingBack = chat([
|
||||
bubble({
|
||||
change: 'react',
|
||||
reactEmoji: '🎉',
|
||||
reactions: [{ emoji: '❤️', count: 1, mine: false, names: ['Jon'] }],
|
||||
}),
|
||||
]);
|
||||
const { rerender } = inView(<CollabChatBody card={takingBack} item={itemOf(takingBack, 'pending')} />);
|
||||
const faint = screen.getByLabelText('Reaction 🎉 taken back');
|
||||
expect(faint.className).toContain('border-dashed');
|
||||
expect(faint.className).toContain('opacity-40');
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabChatBody card={takingBack} item={itemOf(takingBack, 'applied')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.queryByLabelText('Reaction 🎉 taken back')).toBeNull();
|
||||
expect(screen.getByLabelText('❤️ 1: Jon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-007: an image is drawn from TREK by its id, another file named, a cut message says so', () => {
|
||||
const files = chat([
|
||||
bubble({
|
||||
cut: true,
|
||||
files: [
|
||||
{ fileId: 31, image: true, name: 'ticket.jpg' },
|
||||
{ fileId: 32, image: false, name: 'plan.pdf' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
inView(<CollabChatBlock cards={entries(files)} message={MESSAGE} />);
|
||||
expect(screen.getByAltText('ticket.jpg').getAttribute('src')).toBe('/api/trips/4/files/31/download');
|
||||
expect(screen.getByText('plan.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('Continues in the chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-008: on the phone the faces are smaller and the bubbles wider', () => {
|
||||
const thread = chat([bubble()]);
|
||||
inView(<CollabChatBlock cards={entries(thread)} message={MESSAGE} />, { variant: 'fullscreen' });
|
||||
const face = screen.getByText('J');
|
||||
expect(face.getAttribute('style')).toContain('width: 24px');
|
||||
expect(face.parentElement?.nextElementSibling?.getAttribute('style')).toContain('max-width: 85%');
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-009: a message shared from the assistant carries the mark', () => {
|
||||
inView(<CollabChatBlock cards={entries(chat([bubble({ fromAssistant: true })]))} message={MESSAGE} />);
|
||||
expect(screen.getByText('from the assistant')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the poll card', () => {
|
||||
it('FE-AI-COLLAB-010: hides the results until the user voted, then shows the shares and voters', () => {
|
||||
const { rerender } = inView(<CollabPollsBlock cards={entries(polls(poll()))} message={MESSAGE} />);
|
||||
expect(screen.queryByText('67%')).toBeNull();
|
||||
expect(screen.getByText('3 votes')).toBeInTheDocument();
|
||||
const voted = poll({
|
||||
options: [
|
||||
{ text: 'Trattoria Roma', votes: 2, mine: true, voters: [ANNA, JON] },
|
||||
{ text: 'Night market', votes: 1, mine: false, voters: [JON] },
|
||||
],
|
||||
});
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabPollsBlock cards={entries(polls(voted))} message={MESSAGE} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByText('67%')).toBeInTheDocument();
|
||||
expect(screen.getByText('33%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-011: an ended poll shows its results, marks the winner and says it is closed', () => {
|
||||
const { container } = inView(<CollabPollsBlock cards={entries(polls(poll({ closed: true })))} message={MESSAGE} />);
|
||||
expect(screen.getByText('Closed')).toBeInTheDocument();
|
||||
expect(screen.getByText('67%')).toBeInTheDocument();
|
||||
const bars = [...container.querySelectorAll('span[aria-hidden="true"][style]')];
|
||||
expect(bars.some((bar) => bar.getAttribute('style')?.includes('var(--success-soft)'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-012: a held vote rings its option, and nothing on the card votes', () => {
|
||||
const held = polls(
|
||||
poll({
|
||||
change: 'vote',
|
||||
votedIndex: 1,
|
||||
votedAdds: true,
|
||||
options: [
|
||||
{ text: 'Trattoria Roma', votes: 2, mine: false, voters: [ANNA, JON] },
|
||||
{ text: 'Night market', votes: 2, mine: true, voters: [JON, ANNA] },
|
||||
],
|
||||
})
|
||||
);
|
||||
const { container } = inView(<CollabPollsBody card={held} item={itemOf(held, 'pending')} />);
|
||||
expect(screen.getByText('Your vote')).toBeInTheDocument();
|
||||
expect(container.querySelector('.border-dashed')?.textContent).toContain('Night market');
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-013: a refused vote says why, a new poll says it is new, a removed one is struck', () => {
|
||||
const refused = polls(poll({ change: 'vote', closed: true, warning: 'closed', votedIndex: 0 }));
|
||||
const { rerender } = inView(<CollabPollsBody card={refused} item={itemOf(refused, 'pending')} />);
|
||||
expect(screen.getByText('This poll is closed')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your vote')).toBeNull();
|
||||
const created = polls(poll({ pollId: null, change: 'create', moreOptions: 2 }));
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabPollsBody card={created} item={itemOf(created, 'pending')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 more options')).toBeInTheDocument();
|
||||
const removed = polls(poll({ change: 'remove' }));
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabPollsBody card={removed} item={itemOf(removed, 'pending')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByText('Where do we eat on Friday?').className).toContain('line-through');
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-014: counts the polls left out and leads to them', () => {
|
||||
const onOpen = vi.fn();
|
||||
const card: CollabPollsCard = { ...polls(poll()), more: 2 };
|
||||
inView(<CollabPollsBlock cards={entries(card)} message={MESSAGE} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more polls' }));
|
||||
expect(onOpen).toHaveBeenCalledWith(POLLS_TARGET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the note card', () => {
|
||||
it('FE-AI-COLLAB-020: tints the head by the category colour and states the link host, files and category', () => {
|
||||
inView(<CollabNotesBlock cards={entries(notes(note()))} message={MESSAGE} />);
|
||||
expect(screen.getByTestId('note-category-dot').style.background).toBe('rgb(16, 185, 129)');
|
||||
expect(screen.getByText('luigis.example')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 files')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://luigis.example/menu').tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-021: a colour that is no colour falls back to the accent, a pinned note wears its pin', () => {
|
||||
const odd = note({ noteId: 1, title: 'Odd', color: 'url(x)' });
|
||||
const pinned = note({ noteId: 2, title: 'Pinned one', pinned: true });
|
||||
inView(<CollabNotesBlock cards={entries(notes(odd, pinned))} message={MESSAGE} />);
|
||||
expect(screen.getByRole('article', { name: 'Odd' }).querySelector('[style*="var(--accent)"]')).not.toBeNull();
|
||||
expect(screen.getByRole('article', { name: 'Pinned one' }).querySelector('.lucide-pin')).not.toBeNull();
|
||||
expect(screen.getByRole('article', { name: 'Odd' }).querySelector('.lucide-pin')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-022: a held new note has a dashed frame, a note about to go is struck', () => {
|
||||
const created = notes(note({ noteId: null, change: 'create' }));
|
||||
const { rerender } = inView(<CollabNotesBody card={created} item={itemOf(created, 'pending')} />);
|
||||
expect(screen.getByRole('article').className).toContain('border-dashed');
|
||||
const removed = notes(note({ change: 'remove' }));
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
|
||||
<CollabNotesBody card={removed} item={itemOf(removed, 'pending')} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(screen.getByText('Restaurants to try').closest('.line-through')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-023: two notes stand side by side where the chat has room, one under the other where it has not', () => {
|
||||
const pair = entries(notes(note(), note({ noteId: 10, title: 'Second' })));
|
||||
const { container, rerender } = inView(<CollabNotesBlock cards={pair} message={MESSAGE} />, { density: 'roomy' });
|
||||
expect(container.querySelector('.grid-cols-2')).not.toBeNull();
|
||||
rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window', density: 'compact' }}>
|
||||
<CollabNotesBlock cards={pair} message={MESSAGE} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(container.querySelector('.grid-cols-2')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-024: a note change states its title and category before and after, and its pin', () => {
|
||||
const changed = notes(note({ change: 'update', changed: ['title', 'pinned'], pinned: true }));
|
||||
const rows = [
|
||||
{ op: 'change', labelKey: 'name', before: 'Food', after: 'Restaurants to try' },
|
||||
{ op: 'change', labelKey: 'category', before: 'General', after: 'Ideas' },
|
||||
{ op: 'change', labelKey: 'field', field: 'pinned', before: 'no', after: 'yes' },
|
||||
];
|
||||
const { unmount } = inView(<CollabNoteFacts card={changed} item={itemOf(changed, 'pending', rows)} />);
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
unmount();
|
||||
// The pin follows the note the change leaves, whatever glyph the row carries.
|
||||
const unpinned = notes(note({ change: 'update', changed: ['pinned'], pinned: false }));
|
||||
const pinRow = [{ op: 'change', labelKey: 'field', field: 'pinned', before: 'yes', after: 'no' }];
|
||||
inView(<CollabNoteFacts card={unpinned} item={itemOf(unpinned, 'pending', pinRow)} />);
|
||||
expect(screen.queryByText('Pinned')).toBeNull();
|
||||
expect(screen.getByText('No longer pinned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-025: leads to the notes from its head', () => {
|
||||
const onOpen = vi.fn();
|
||||
inView(<CollabNotesBlock cards={entries(notes(note()))} message={MESSAGE} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open the notes/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith(NOTES_TARGET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the look and the model', () => {
|
||||
it('FE-AI-COLLAB-030: names every collab tool and read, and opens the Collab tab on its part', () => {
|
||||
for (const tool of ['delete_collab_message', 'create_collab_poll', 'vote_collab_poll', 'update_collab_note']) {
|
||||
expect(TOOL_LABELS[tool]?.labelKey).toBe(`assistant.tool.${tool}`);
|
||||
}
|
||||
expect(STEP_LOOKS.list_collab_messages?.stepKey).toBe('assistant.step.collabChat');
|
||||
expect(STEP_LOOKS.list_collab_notes?.stepKey).toBe('assistant.step.collabNotes');
|
||||
const applied = { toolCallId: 'c', ids: [1], dayId: null, undoUntil: null };
|
||||
expect(ENTITY_LOOK.collab_message.open?.({ ...applied, entity: 'collab_message' }, null)).toEqual(CHAT_TARGET);
|
||||
expect(ENTITY_LOOK.collab_poll.open?.({ ...applied, entity: 'collab_poll' }, null)).toEqual(POLLS_TARGET);
|
||||
expect(ENTITY_LOOK.collab_note.open?.({ ...applied, entity: 'collab_note' }, null)).toEqual(NOTES_TARGET);
|
||||
expect(CARD_LOOKS.collabPolls.entity?.(polls(poll()))).toBe('collab_poll');
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-031: a read of the one poll or note a change touches gives way to it; a list or the chat stays', () => {
|
||||
const vote = polls(poll({ change: 'vote', votedIndex: 0, votedAdds: true }));
|
||||
expect(readServedChange(polls(poll()), vote)).toBe(true);
|
||||
expect(CARD_RULES.yieldsToChange(polls(poll()), vote)).toBe(true);
|
||||
expect(readServedChange(polls(poll(), poll({ pollId: 6 })), vote)).toBe(false);
|
||||
expect(readServedChange(polls(poll({ pollId: 6 })), vote)).toBe(false);
|
||||
expect(readServedChange(notes(note()), notes(note({ change: 'update' })))).toBe(true);
|
||||
expect(CARD_RULES.yieldsToChange(chat([bubble()]), chat([bubble({ change: 'react' })]))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-COLLAB-032: groups bubbles by sender and day, and reads a poll by its outcome', () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-22T12:00:00Z') });
|
||||
const run = [bubble(), bubble({ id: 2 }), bubble({ id: 3, author: ANNA, at: '2026-10-22T09:00:00Z' })];
|
||||
expect(spotOf(run, 0)).toEqual({ newGroup: true, lastInGroup: false, newDay: true });
|
||||
expect(spotOf(run, 1)).toEqual({ newGroup: false, lastInGroup: true, newDay: false });
|
||||
expect(spotOf(run, 2)).toEqual({ newGroup: true, lastInGroup: true, newDay: true });
|
||||
const merged = mergedChat(entries(chat([bubble({ id: null, at: null }), bubble({ id: 4 })], 2), chat(run)));
|
||||
expect(merged.messages.map((b) => b.id)).toEqual([1, 2, 4, 3, null]);
|
||||
expect(merged.earlier).toBe(2);
|
||||
expect(bubbleStatus(bubble({ change: 'send', at: null }), 'pending')).toBe('waiting');
|
||||
expect(bubbleStatus(bubble({ change: 'send' }), 'discarded')).toBe('none');
|
||||
expect(bubbleStatus(bubble(), null)).toBe('time');
|
||||
const ended = pollOutcome(poll({ deadline: '2026-10-21T00:00:00Z' }));
|
||||
expect(ended).toMatchObject({ ended: true, showResults: true, top: 2 });
|
||||
expect(pollOutcome(poll())).toMatchObject({ total: 3, ended: false, showResults: false });
|
||||
expect(shareOf(2, 3)).toBe(67);
|
||||
expect(shareOf(0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AssistantMessage, CollabChatCard as ChatCard } from '@trek/shared';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { mergedChat } from './collabCardModel';
|
||||
import CollabChatThread from './CollabChatThread';
|
||||
import { CHAT_TARGET } from './collabLook';
|
||||
|
||||
/**
|
||||
* The trip chat in the assistant chat: an answer as the last messages a read
|
||||
* found, on the chat panel's own quieter surface, with the older ones counted
|
||||
* and a way into the chat; a change row as the one bubble it sends, removes
|
||||
* or reacts to, as it will look.
|
||||
*/
|
||||
|
||||
/** The messages of an answer's chat reads, in one thread. */
|
||||
export default function CollabChatBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { messages, earlier } = mergedChat(cards);
|
||||
if (messages.length === 0) return null;
|
||||
const title = t('assistant.card.collab.chatTitle');
|
||||
return (
|
||||
<CardFrame label={title} surface="secondary">
|
||||
<CardHeader
|
||||
icon={MessageCircle}
|
||||
tone="info"
|
||||
title={title}
|
||||
actions={<CardOpenButton target={CHAT_TARGET} labelKey="assistant.card.collab.openChat" />}
|
||||
/>
|
||||
<div className="mt-2 flex flex-col">
|
||||
{earlier > 0 && (
|
||||
<span className="self-center rounded-full bg-surface-card px-3 py-0.5 text-caption text-content-faint">
|
||||
{t('assistant.card.collab.earlier', { count: earlier })}
|
||||
</span>
|
||||
)}
|
||||
<CollabChatThread messages={messages} />
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** A change row's bubble, on the chat panel's surface, marked by how the change stands. */
|
||||
export function CollabChatBody({ card, item }: { card: ChatCard; item: ChangeItem }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-secondary px-2 pb-1.5 pt-0.5">
|
||||
<CollabChatThread messages={card.messages} state={item.state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { CollabBubbleCard } from '@trek/shared';
|
||||
import { Check, Clock, Paperclip, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc';
|
||||
import CollabAssistantBadge from '../../../Collab/CollabAssistantBadge';
|
||||
import {
|
||||
ChatAvatar,
|
||||
ChatBigEmoji,
|
||||
ChatBubble,
|
||||
ChatDateSeparator,
|
||||
ChatReactions,
|
||||
ChatSenderName,
|
||||
ChatTime,
|
||||
} from '../../../Collab/CollabChatBubble';
|
||||
import { MessageText } from '../../../Collab/CollabChatMessageText';
|
||||
import { TwemojiImg } from '../../../Collab/CollabChatTwemojiImg';
|
||||
import { formatChatClockTime, formatChatDateSeparator, isEmojiOnlyText } from '../../../Collab/collabModel';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import type { ChangeState } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { useViewerId } from '../parts/useViewerId';
|
||||
import { type BubbleSpot, bubbleStatus, isDropped, spotOf } from './collabCardModel';
|
||||
|
||||
/**
|
||||
* Messages of the trip chat drawn as the chat draws them, from the pieces the
|
||||
* Collab tab uses: the others on one side with their face and name, the
|
||||
* user's own on the other in the accent, a day between days, the reactions
|
||||
* floating under a bubble, the time under the last of a run. A read-only
|
||||
* copy: no hover actions, links read as words, only the tooltips answer. A
|
||||
* held change marks its bubble: a send says it is not sent yet (then sent or
|
||||
* not sent), a delete strikes it, a reaction rings the emoji it toggles (and
|
||||
* keeps a faint pill for one it takes away).
|
||||
*/
|
||||
|
||||
/** One reaction as the chat's floating pill shows it, the names in its tooltip. */
|
||||
function Reaction({ reaction, held }: { reaction: CollabBubbleCard['reactions'][number]; held: boolean }) {
|
||||
const names = reaction.names.join(', ');
|
||||
return (
|
||||
<Tooltip label={names || reaction.emoji}>
|
||||
<span
|
||||
aria-label={`${reaction.emoji} ${reaction.count}${names ? `: ${names}` : ''}`}
|
||||
className={`inline-flex items-center gap-0.5 rounded-full px-[3px] py-px ${held ? 'border border-dashed border-accent' : ''}`}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && (
|
||||
<span className="min-w-2 text-caption font-bold tabular-nums text-content-muted">{reaction.count}</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reaction a held toggle takes away when it was the user's only one on
|
||||
* that emoji: the server drops its pill, so the card keeps it faint in a
|
||||
* dashed ring until the change is applied, rather than showing nothing.
|
||||
*/
|
||||
function TakenBack({ emoji }: { emoji: string }) {
|
||||
const { t } = useTranslation();
|
||||
const label = t('assistant.card.collab.reactionRemoved', { emoji });
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
aria-label={label}
|
||||
className="inline-flex items-center rounded-full border border-dashed border-accent px-[3px] py-px opacity-40"
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A file sent with a message: an image in the bubble, any other file by its name. */
|
||||
function ChatFile({ file, tripId }: { file: CollabBubbleCard['files'][number]; tripId: number }) {
|
||||
const { t } = useTranslation();
|
||||
if (file.image) {
|
||||
return (
|
||||
<img
|
||||
src={`/api/trips/${tripId}/files/${file.fileId}/download`}
|
||||
alt={file.name ?? t('collab.chat.attachedImage')}
|
||||
className="mt-1 block max-h-40 w-[140px] max-w-full rounded-[10px] object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mt-1 flex max-w-full" title={file.name ?? undefined}>
|
||||
<SoftPill icon={<Paperclip size={10} strokeWidth={2.2} aria-hidden="true" />} className="max-w-full">
|
||||
<bdi className="truncate">{file.name ?? t('collab.chat.attachedImage')}</bdi>
|
||||
</SoftPill>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The time slot under the last bubble of a run: the time, or how a held send stands. */
|
||||
function Status({ bubble, state }: { bubble: CollabBubbleCard; state: ChangeState | null }) {
|
||||
const { t } = useTranslation();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
|
||||
const status = bubbleStatus(bubble, state);
|
||||
const time = bubble.at ? formatChatClockTime(bubble.at, is12h) : null;
|
||||
if (status === 'none') return null;
|
||||
if (status === 'waiting') {
|
||||
return (
|
||||
<ChatTime>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={10} aria-hidden="true" />
|
||||
{t('assistant.card.collab.notSent')}
|
||||
</span>
|
||||
</ChatTime>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<ChatTime>
|
||||
<span className="inline-flex items-center gap-1 text-danger">
|
||||
<TriangleAlert size={10} aria-hidden="true" />
|
||||
{t('assistant.card.collab.failed')}
|
||||
</span>
|
||||
</ChatTime>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChatTime>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{time}
|
||||
{status === 'sent' && (
|
||||
<Check
|
||||
size={10}
|
||||
strokeWidth={2.6}
|
||||
role="img"
|
||||
aria-label={t('assistant.card.collab.sent')}
|
||||
className="text-success"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</ChatTime>
|
||||
);
|
||||
}
|
||||
|
||||
/** A removed message once the removal was applied: the chat's own placeholder line. */
|
||||
function Removed() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<span className="text-caption italic text-content-faint">{t('assistant.card.collab.youDeleted')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What one bubble of a thread is drawn with. */
|
||||
interface BubbleProps {
|
||||
bubble: CollabBubbleCard;
|
||||
spot: BubbleSpot;
|
||||
own: boolean;
|
||||
state: ChangeState | null;
|
||||
}
|
||||
|
||||
/** The face beside someone else's bubble, on the last of their run; smaller on the phone. */
|
||||
function Face({ bubble, spot }: Pick<BubbleProps, 'bubble' | 'spot'>) {
|
||||
const { variant } = useAssistantView();
|
||||
const size = variant === 'fullscreen' ? 24 : 28;
|
||||
return (
|
||||
<div className="flex-none self-end" style={{ width: size }}>
|
||||
{spot.lastInGroup && (
|
||||
<ChatAvatar name={bubble.author.name} src={avatarSrc(bubble.author.avatarKey)} size={size} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The bubble itself (or emoji alone, drawn large) with its files. */
|
||||
function Words({ bubble, spot, own }: Pick<BubbleProps, 'bubble' | 'spot' | 'own'>) {
|
||||
const { tripId } = useAssistantView();
|
||||
if (bubble.files.length === 0 && isEmojiOnlyText(bubble.text)) return <ChatBigEmoji>{bubble.text}</ChatBigEmoji>;
|
||||
return (
|
||||
<ChatBubble
|
||||
own={own}
|
||||
lastInGroup={spot.lastInGroup}
|
||||
reply={bubble.reply}
|
||||
text={<MessageText text={bubble.text} plain />}
|
||||
>
|
||||
{bubble.files.map((file) => <ChatFile key={file.fileId} file={file} tripId={tripId} />)}
|
||||
</ChatBubble>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything in the column of one bubble: the name, the assistant mark, the bubble, its reactions and its time. */
|
||||
function Column({ bubble, spot, own, state }: BubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const reacting = bubble.change === 'react' && state !== 'applied';
|
||||
const held = (emoji: string) => reacting && emoji === bubble.reactEmoji;
|
||||
const takenBack =
|
||||
reacting && bubble.reactEmoji && !bubble.reactions.some((r) => r.emoji === bubble.reactEmoji)
|
||||
? bubble.reactEmoji
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 flex-col ${own ? 'items-end' : 'items-start'}`}
|
||||
style={{ maxWidth: variant === 'fullscreen' ? '85%' : '78%' }}
|
||||
>
|
||||
{!own && spot.newGroup && <ChatSenderName>{bubble.author.name}</ChatSenderName>}
|
||||
{bubble.fromAssistant && (
|
||||
<span className="mb-0.5 ps-1">
|
||||
<CollabAssistantBadge name={bubble.author.name} />
|
||||
</span>
|
||||
)}
|
||||
<div className={bubble.change === 'remove' ? 'line-through' : ''}>
|
||||
<Words bubble={bubble} spot={spot} own={own} />
|
||||
</div>
|
||||
{(bubble.reactions.length > 0 || takenBack) && (
|
||||
<ChatReactions own={own}>
|
||||
{bubble.reactions.map((reaction) => (
|
||||
<Reaction key={reaction.emoji} reaction={reaction} held={held(reaction.emoji)} />
|
||||
))}
|
||||
{takenBack && <TakenBack emoji={takenBack} />}
|
||||
</ChatReactions>
|
||||
)}
|
||||
{bubble.cut && (
|
||||
<span className="px-1 text-caption text-content-faint">{t('assistant.card.collab.continues')}</span>
|
||||
)}
|
||||
{spot.lastInGroup && <Status bubble={bubble} state={state} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One message: the day before it when it changes, then its row on its sender's side. */
|
||||
function BubbleRow({ bubble, spot, own, state }: BubbleProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const removing = bubble.change === 'remove';
|
||||
const day = spot.newDay && bubble.at && <ChatDateSeparator label={formatChatDateSeparator(bubble.at, t, locale)} />;
|
||||
if (removing && state === 'applied') {
|
||||
return (
|
||||
<>
|
||||
{day}
|
||||
<Removed />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const dimmed = removing || (bubble.change === 'send' && isDropped(state));
|
||||
return (
|
||||
<>
|
||||
{day}
|
||||
<div
|
||||
className={`flex gap-1.5 ${own ? 'flex-row-reverse items-end' : 'flex-row items-start'} ${dimmed ? 'opacity-60' : ''}`}
|
||||
style={{ marginTop: spot.newGroup ? 10 : 1, paddingInlineStart: own ? 40 : 0, paddingInlineEnd: own ? 0 : 40 }}
|
||||
>
|
||||
{!own && <Face bubble={bubble} spot={spot} />}
|
||||
<Column bubble={bubble} spot={spot} own={own} state={state} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CollabChatThread({
|
||||
messages,
|
||||
state = null,
|
||||
}: {
|
||||
messages: readonly CollabBubbleCard[];
|
||||
/** The decision on the change the thread shows; null for an answer. */
|
||||
state?: ChangeState | null;
|
||||
}) {
|
||||
const viewerId = useViewerId();
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{messages.map((bubble, index) => (
|
||||
<BubbleRow
|
||||
key={bubble.id ?? `new-${index}`}
|
||||
bubble={bubble}
|
||||
spot={spotOf(messages, index)}
|
||||
own={bubble.author.id === viewerId}
|
||||
state={state}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { AssistantMessage, CollabNoteCard, CollabNotesCard as NotesCard } from '@trek/shared';
|
||||
import { Link2, Paperclip, Pin, PinOff, StickyNote, Tag, Type } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import Markdown, { type Components } from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { safeHexColor } from '../../../../utils/safeColor';
|
||||
import { NoteHeadBand } from '../../../Collab/CollabNoteHead';
|
||||
import { SoftPill, tintOf } from '../../../Planner/planParts';
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../../../shared/markdownSanitize';
|
||||
import type { ChangeItem, ChangeState } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { Fact, FactChange } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import { isChanged, isOpen } from './collabCardModel';
|
||||
import { NOTES_TARGET } from './collabLook';
|
||||
|
||||
/**
|
||||
* Shared notes of the trip in the assistant chat, drawn like the Collab
|
||||
* tab's note cards: a head band tinted by the category's colour with its
|
||||
* title and pin, the start of the text in three lines, and the facts (the
|
||||
* link's host, the files, the category) as badges. A link in a note reads as
|
||||
* a word here, since the text may be cut. A held new note has a dashed
|
||||
* frame, a held change marks what it changes, a note about to go is struck.
|
||||
*/
|
||||
|
||||
const ICON = { size: 10, strokeWidth: 2.2, 'aria-hidden': true } as const;
|
||||
|
||||
/** The note's markdown as the note card renders it, its links as words. */
|
||||
const NOTE_MARKDOWN: Components = {
|
||||
...sanitizedMarkdownComponents,
|
||||
a: ({ children }) => <span className="underline underline-offset-2">{children}</span>,
|
||||
};
|
||||
|
||||
/** A small accent dot before what a held change sets anew. */
|
||||
function ChangedDot({ on }: { on: boolean }) {
|
||||
if (!on) return null;
|
||||
return <span aria-hidden="true" className="me-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle" />;
|
||||
}
|
||||
|
||||
function NoteView({ note, state }: { note: CollabNoteCard; state: ChangeState | null }) {
|
||||
const { t } = useTranslation();
|
||||
const color = safeHexColor(note.color, 'var(--accent)');
|
||||
const removing = note.change === 'remove';
|
||||
const dashed = note.change === 'create' && isOpen(state);
|
||||
const facts: ReactNode[] = [];
|
||||
if (note.websiteHost) {
|
||||
facts.push(
|
||||
<SoftPill key="link" icon={<Link2 {...ICON} />} className="max-w-full">
|
||||
<ChangedDot on={isChanged(note, 'website')} />
|
||||
<bdi className="truncate">{note.websiteHost}</bdi>
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
if (note.files > 0) {
|
||||
facts.push(
|
||||
<SoftPill key="files" icon={<Paperclip {...ICON} />}>
|
||||
{t('assistant.card.collab.files', { count: note.files })}
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
if (note.category) {
|
||||
facts.push(
|
||||
<SoftPill key="category" icon={<Tag {...ICON} />} className="max-w-full">
|
||||
<ChangedDot on={isChanged(note, 'category')} />
|
||||
<bdi className="truncate">{note.category}</bdi>
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
const hasBody = !!note.body || facts.length > 0;
|
||||
return (
|
||||
<article
|
||||
aria-label={note.title}
|
||||
className={`flex min-w-0 flex-col overflow-hidden rounded-2xl border bg-surface-card ${dashed ? 'border-dashed' : ''} ${removing ? 'opacity-60' : ''}`}
|
||||
// A pinned note keeps its category colour on the frame, as on the Collab tab.
|
||||
style={{
|
||||
borderColor: note.pinned ? tintOf(color, 45) : dashed ? 'var(--border-primary)' : 'var(--border-faint)',
|
||||
}}
|
||||
>
|
||||
<NoteHeadBand
|
||||
title={
|
||||
<>
|
||||
<ChangedDot on={isChanged(note, 'title')} />
|
||||
{note.title}
|
||||
</>
|
||||
}
|
||||
color={color}
|
||||
category={note.category}
|
||||
pinned={note.pinned}
|
||||
bordered={hasBody}
|
||||
titleClassName={removing ? 'line-through' : ''}
|
||||
trailing={note.author && <PersonFace person={note.author} size={20} />}
|
||||
/>
|
||||
{hasBody && (
|
||||
<div className="flex flex-1 flex-col gap-2 px-3 pb-2.5 pt-2">
|
||||
{note.body && (
|
||||
<div className="collab-note-md line-clamp-3 break-words text-caption text-content-muted">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={sanitizedMarkdownPlugins}
|
||||
components={NOTE_MARKDOWN}
|
||||
>
|
||||
{note.body}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{facts.length > 0 && <span className="flex flex-wrap items-center gap-1">{facts}</span>}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The notes of an answer's reads, each once; side by side where the chat has room, the rest counted. */
|
||||
export default function CollabNotesBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const seen = new Set<number>();
|
||||
const notes: CollabNoteCard[] = [];
|
||||
let more = 0;
|
||||
for (const { card } of cards) {
|
||||
if (card.kind !== 'collabNotes') continue;
|
||||
more = Math.max(more, card.more);
|
||||
for (const note of card.notes) {
|
||||
if (note.noteId !== null && seen.has(note.noteId)) continue;
|
||||
if (note.noteId !== null) seen.add(note.noteId);
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
if (notes.length === 0) return null;
|
||||
const title = t('collab.tabs.notes');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<CardHeader
|
||||
icon={StickyNote}
|
||||
tone="info"
|
||||
title={title}
|
||||
count={notes.length + more}
|
||||
actions={<CardOpenButton target={NOTES_TARGET} labelKey="assistant.card.collab.openNotes" />}
|
||||
/>
|
||||
<div className={`mt-2 gap-2 ${density === 'roomy' ? 'grid grid-cols-2' : 'flex flex-col'}`}>
|
||||
{notes.map((note, index) => <NoteView key={note.noteId ?? `new-${index}`} note={note} state={null} />)}
|
||||
</div>
|
||||
<CardMore count={more} labelKey="assistant.card.collab.moreNotes" target={NOTES_TARGET} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** A change row's note, after the change, marked by how it stands. */
|
||||
export function CollabNotesBody({ card, item }: { card: NotesCard; item: ChangeItem }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{card.notes.map((note, index) => <NoteView key={note.noteId ?? `new-${index}`} note={note} state={item.state} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The faint line of a note change: its title and category before and after
|
||||
* from the change's own rows, and whether it gets pinned or unpinned, read
|
||||
* from the note the change leaves.
|
||||
*/
|
||||
export function CollabNoteFacts({ card, item }: { card: NotesCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = item.preview?.rows ?? [];
|
||||
// The pin reads from the note as the change leaves it, not from the row's check glyph.
|
||||
const pinned = card.notes[0]?.pinned === true;
|
||||
return (
|
||||
<>
|
||||
{rows.map((row, index) => {
|
||||
if (row.labelKey === 'name') {
|
||||
const key = `${row.labelKey}-${index}`;
|
||||
return <FactChange key={key} icon={Type} before={row.before} after={row.after} tone="info" />;
|
||||
}
|
||||
if (row.labelKey === 'category') {
|
||||
return <FactChange key={`${row.labelKey}-${index}`} icon={Tag} before={row.before} after={row.after} />;
|
||||
}
|
||||
if (row.labelKey === 'field' && row.field === 'pinned') {
|
||||
return (
|
||||
<Fact
|
||||
key={`${row.labelKey}-${index}`}
|
||||
icon={pinned ? Pin : PinOff}
|
||||
label={t(pinned ? 'assistant.card.collab.pinned' : 'assistant.card.collab.unpinned')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { AssistantMessage, CollabPollCard, CollabPollsCard as PollsCard } from '@trek/shared';
|
||||
import { BarChart3, Clock, Lock, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc';
|
||||
import { formatPollCountdown } from '../../../Collab/collabModel';
|
||||
import { PollChip, PollOptionRow } from '../../../Collab/CollabPollOption';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { NEUTRAL_TINT } from '../../../shared/DialogShell';
|
||||
import type { ChangeItem, ChangeState } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import { isOpen, pollOutcome, shareOf } from './collabCardModel';
|
||||
import { POLLS_TARGET } from './collabLook';
|
||||
|
||||
/**
|
||||
* Polls of the trip in the assistant chat, drawn like the Collab tab's poll
|
||||
* cards: the question on a tinted band with its facts as chips, the options
|
||||
* with their bars, shares and voters once the user voted or the poll ended,
|
||||
* the winner of an ended poll in green. Nothing here votes: the options are
|
||||
* rows, not buttons. A held vote rings the option it toggles, a held poll
|
||||
* that does not exist yet says it is new, one about to go is struck.
|
||||
*/
|
||||
|
||||
/** Why a held vote would be refused, in words. A table, so each key is named where it is read. */
|
||||
const WARNING = {
|
||||
closed: { labelKey: 'assistant.card.collab.pollClosedWarning' },
|
||||
unknownOption: { labelKey: 'assistant.card.collab.unknownOption' },
|
||||
} as const satisfies Record<NonNullable<CollabPollCard['warning']>, { labelKey: string }>;
|
||||
|
||||
function PollView({ poll, state }: { poll: CollabPollCard; state: ChangeState | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { total, ended, showResults, top } = pollOutcome(poll);
|
||||
const remaining = formatPollCountdown(poll.deadline, t);
|
||||
const removing = poll.change === 'remove';
|
||||
const heldVote = poll.change === 'vote' && isOpen(state) && poll.warning === null;
|
||||
const marked = (index: number) =>
|
||||
heldVote && index === poll.votedIndex
|
||||
? { label: t(poll.votedAdds ? 'assistant.card.collab.yourVote' : 'assistant.card.collab.voteTakenBack') }
|
||||
: null;
|
||||
return (
|
||||
<article
|
||||
aria-label={poll.question}
|
||||
className={`overflow-hidden rounded-2xl border border-edge-faint bg-surface-card ${removing ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-edge-faint px-3 py-2.5"
|
||||
style={{ background: ended ? 'var(--bg-secondary)' : NEUTRAL_TINT }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`m-0 whitespace-pre-wrap break-words text-body font-bold leading-snug text-content ${removing ? 'line-through' : ''}`}
|
||||
>
|
||||
{poll.question}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{poll.change === 'create' && <PollChip tone="info">{t('assistant.card.collab.newPoll')}</PollChip>}
|
||||
{ended && <PollChip icon={<Lock size={9} strokeWidth={2.4} />}>{t('collab.polls.closed')}</PollChip>}
|
||||
{remaining && !ended && (
|
||||
<PollChip tone="warning" icon={<Clock size={9} strokeWidth={2.4} />}>
|
||||
{remaining}
|
||||
</PollChip>
|
||||
)}
|
||||
{poll.multiple && <PollChip>{t('collab.polls.multiChoice')}</PollChip>}
|
||||
<PollChip>{t('collab.polls.votes', { n: total })}</PollChip>
|
||||
</div>
|
||||
</div>
|
||||
{poll.author && <PersonFace person={poll.author} size={20} />}
|
||||
</div>
|
||||
{poll.warning && (
|
||||
<div className="px-3 pt-2.5">
|
||||
<SoftPill tone="warning" icon={<TriangleAlert size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(WARNING[poll.warning].labelKey)}
|
||||
</SoftPill>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
{poll.options.map((option, index) => (
|
||||
<PollOptionRow
|
||||
key={`${index}-${option.text}`}
|
||||
as="div"
|
||||
text={option.text}
|
||||
pct={shareOf(option.votes, total)}
|
||||
mine={option.mine}
|
||||
winner={ended && option.votes > 0 && option.votes === top}
|
||||
showResults={showResults}
|
||||
voters={option.voters.map((voter) => ({
|
||||
key: voter.id,
|
||||
name: voter.name,
|
||||
src: avatarSrc(voter.avatarKey),
|
||||
}))}
|
||||
pending={marked(index)}
|
||||
/>
|
||||
))}
|
||||
{poll.moreOptions > 0 && (
|
||||
<p className="m-0 px-1 text-caption text-content-faint">
|
||||
{t('assistant.card.collab.moreOptions', { count: poll.moreOptions })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The polls of an answer's reads, open ones first as they came, each once; the rest counted with a way to them. */
|
||||
export default function CollabPollsBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const seen = new Set<number>();
|
||||
const polls: CollabPollCard[] = [];
|
||||
let more = 0;
|
||||
for (const { card } of cards) {
|
||||
if (card.kind !== 'collabPolls') continue;
|
||||
more = Math.max(more, card.more);
|
||||
for (const poll of card.polls) {
|
||||
if (poll.pollId !== null && seen.has(poll.pollId)) continue;
|
||||
if (poll.pollId !== null) seen.add(poll.pollId);
|
||||
polls.push(poll);
|
||||
}
|
||||
}
|
||||
if (polls.length === 0) return null;
|
||||
const title = t('collab.tabs.polls');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<CardHeader
|
||||
icon={BarChart3}
|
||||
tone="info"
|
||||
title={title}
|
||||
count={polls.length + more}
|
||||
actions={<CardOpenButton target={POLLS_TARGET} labelKey="assistant.card.collab.openPolls" />}
|
||||
/>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{polls.map((poll, index) => <PollView key={poll.pollId ?? `new-${index}`} poll={poll} state={null} />)}
|
||||
</div>
|
||||
<CardMore count={more} labelKey="assistant.card.collab.morePolls" target={POLLS_TARGET} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** A change row's poll, after the change, marked by how it stands. */
|
||||
export function CollabPollsBody({ card, item }: { card: PollsCard; item: ChangeItem }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{card.polls.map((poll, index) => <PollView key={poll.pollId ?? `new-${index}`} poll={poll} state={item.state} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CollabBubbleCard, CollabNoteCard, CollabPollCard } from '@trek/shared';
|
||||
|
||||
import { isPollExpired, parseUtcDate } from '../../../Collab/collabModel';
|
||||
import type { ChangeState } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* The pure side of the trip chat's cards: how the bubbles of several reads
|
||||
* of one answer merge, how a run of bubbles groups the way the chat groups
|
||||
* them, and what a poll shows of its results. No component here, so the
|
||||
* tests read it without a render.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The bubbles of an answer's chat cards in one thread: each message once,
|
||||
* in the order they were written, a message still waiting to be sent last;
|
||||
* the older messages the reads left out are counted once.
|
||||
*/
|
||||
export function mergedChat(cards: readonly CardEntry[]): { messages: CollabBubbleCard[]; earlier: number } {
|
||||
const byId = new Map<number, CollabBubbleCard>();
|
||||
const waiting: CollabBubbleCard[] = [];
|
||||
let earlier = 0;
|
||||
for (const { card } of cards) {
|
||||
if (card.kind !== 'collabChat') continue;
|
||||
earlier = Math.max(earlier, card.earlier);
|
||||
for (const bubble of card.messages) {
|
||||
if (bubble.id === null) waiting.push(bubble);
|
||||
else byId.set(bubble.id, bubble);
|
||||
}
|
||||
}
|
||||
const sent = [...byId.values()].sort((a, b) => (a.at ?? '').localeCompare(b.at ?? '') || (a.id ?? 0) - (b.id ?? 0));
|
||||
return { messages: [...sent, ...waiting], earlier };
|
||||
}
|
||||
|
||||
/** Where a bubble stands in its run, as the chat lays it out. */
|
||||
export interface BubbleSpot {
|
||||
/** The first of the sender's run: their name goes over it. */
|
||||
newGroup: boolean;
|
||||
/** The last of the sender's run: it gets the tail, the face and the time. */
|
||||
lastInGroup: boolean;
|
||||
/** The day changes before it. */
|
||||
newDay: boolean;
|
||||
}
|
||||
|
||||
function dayOf(at: string | null): string | null {
|
||||
return at === null ? null : parseUtcDate(at).toDateString();
|
||||
}
|
||||
|
||||
export function spotOf(messages: readonly CollabBubbleCard[], index: number): BubbleSpot {
|
||||
const bubble = messages[index];
|
||||
const before = messages[index - 1];
|
||||
const after = messages[index + 1];
|
||||
const day = dayOf(bubble?.at ?? null);
|
||||
return {
|
||||
newGroup: !before || before.author.id !== bubble?.author.id,
|
||||
lastInGroup: !after || after.author.id !== bubble?.author.id,
|
||||
newDay: day !== null && (!before || dayOf(before.at) !== day),
|
||||
};
|
||||
}
|
||||
|
||||
/** What stands in a bubble's time slot while its change is open or once it was decided. */
|
||||
type BubbleStatus = 'time' | 'waiting' | 'sent' | 'failed' | 'none';
|
||||
|
||||
/** The time slot of a bubble: a held send says it is not sent yet, then sent or failed, a dropped one nothing. */
|
||||
export function bubbleStatus(bubble: CollabBubbleCard, state: ChangeState | null): BubbleStatus {
|
||||
if (bubble.change !== 'send' || state === null) return bubble.at ? 'time' : 'none';
|
||||
if (state === 'pending' || state === 'applying') return 'waiting';
|
||||
if (state === 'applied') return 'sent';
|
||||
if (state === 'failed') return 'failed';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** What a poll shows of its outcome, by PollCard's rules. */
|
||||
interface PollOutcome {
|
||||
total: number;
|
||||
/** Closed by hand or past its deadline. */
|
||||
ended: boolean;
|
||||
/** Shares and voters show once the user voted or the poll ended. */
|
||||
showResults: boolean;
|
||||
/** The most votes one option has. */
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function pollOutcome(poll: CollabPollCard): PollOutcome {
|
||||
const total = poll.options.reduce((sum, option) => sum + option.votes, 0);
|
||||
const ended = poll.closed || isPollExpired(poll.deadline);
|
||||
const top = poll.options.reduce((max, option) => Math.max(max, option.votes), 0);
|
||||
return { total, ended, showResults: ended || poll.options.some((option) => option.mine), top };
|
||||
}
|
||||
|
||||
/** An option's share of the votes, in whole percent. */
|
||||
export function shareOf(votes: number, total: number): number {
|
||||
return total > 0 ? Math.round((votes / total) * 100) : 0;
|
||||
}
|
||||
|
||||
/** Whether a change row's card is still waiting, or settled without being applied. */
|
||||
export function isOpen(state: ChangeState | null): boolean {
|
||||
return state === 'pending' || state === 'applying';
|
||||
}
|
||||
|
||||
export function isDropped(state: ChangeState | null): boolean {
|
||||
return state === 'discarded' || state === 'expired' || state === 'failed';
|
||||
}
|
||||
|
||||
/** Whether a note's part is one the held change sets to something new. */
|
||||
export function isChanged(note: CollabNoteCard, field: CollabNoteCard['changed'][number]): boolean {
|
||||
return note.change === 'update' && note.changed.includes(field);
|
||||
}
|
||||
@@ -1,20 +1,65 @@
|
||||
import type { PackLook } from '../cardTypes';
|
||||
import type { TrekCard } from '@trek/shared';
|
||||
import { BarChart3, MessageCircle, StickyNote } from 'lucide-react';
|
||||
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* What the trip chat, polls and notes bring to the chat's pure look: the verbs,
|
||||
* tool names and steps they add, what an applied change of theirs opens, and
|
||||
* the look of each of their kinds. Empty until the collab cards fill it. A look
|
||||
* stays pure, icons and literals only: the chat's main chunk reads every look,
|
||||
* so whatever a look imports ships with it.
|
||||
* What the trip chat, polls and notes bring to the chat's pure look: the tool
|
||||
* names of their changes (the verbs stay add, change and remove), the steps
|
||||
* of their reads, what an applied change of theirs opens (the Collab tab, on
|
||||
* its chat, polls or notes) and the look of each of their kinds. A look stays
|
||||
* pure, icons and literals only: the chat's main chunk reads every look, so
|
||||
* whatever a look imports ships with it.
|
||||
*/
|
||||
|
||||
export const CHAT_TARGET: EntityTarget = { kind: 'tab', tab: 'collab', sub: 'chat' };
|
||||
export const POLLS_TARGET: EntityTarget = { kind: 'tab', tab: 'collab', sub: 'polls' };
|
||||
export const NOTES_TARGET: EntityTarget = { kind: 'tab', tab: 'collab', sub: 'notes' };
|
||||
|
||||
/** The one poll or note a card holds, by its id; none for a list or a thing not saved yet. */
|
||||
function soleId(card: TrekCard): number | null {
|
||||
if (card.kind === 'collabPolls') return card.polls.length === 1 ? (card.polls[0]?.pollId ?? null) : null;
|
||||
if (card.kind === 'collabNotes') return card.notes.length === 1 ? (card.notes[0]?.noteId ?? null) : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read that only looked up the one poll or note a change of the same
|
||||
* answer then touches (the poll it votes in, the note it edits) gives way to
|
||||
* the change card, which shows it already. A list stays, and so does the
|
||||
* chat: the messages around a reaction are what the user asked to see.
|
||||
*/
|
||||
export function readServedChange(answer: TrekCard, change: TrekCard): boolean {
|
||||
const id = soleId(answer);
|
||||
return id !== null && answer.kind === change.kind && soleId(change) === id;
|
||||
}
|
||||
|
||||
export const COLLAB_LOOK = {
|
||||
verbs: {},
|
||||
tools: {},
|
||||
steps: {},
|
||||
entities: {},
|
||||
tools: {
|
||||
delete_collab_message: { labelKey: 'assistant.tool.delete_collab_message' },
|
||||
react_collab_message: { labelKey: 'assistant.tool.react_collab_message' },
|
||||
create_collab_poll: { labelKey: 'assistant.tool.create_collab_poll' },
|
||||
vote_collab_poll: { labelKey: 'assistant.tool.vote_collab_poll' },
|
||||
close_collab_poll: { labelKey: 'assistant.tool.close_collab_poll' },
|
||||
delete_collab_poll: { labelKey: 'assistant.tool.delete_collab_poll' },
|
||||
create_collab_note: { labelKey: 'assistant.tool.create_collab_note' },
|
||||
update_collab_note: { labelKey: 'assistant.tool.update_collab_note' },
|
||||
delete_collab_note: { labelKey: 'assistant.tool.delete_collab_note' },
|
||||
},
|
||||
steps: {
|
||||
list_collab_messages: { stepKey: 'assistant.step.collabChat' },
|
||||
list_collab_polls: { stepKey: 'assistant.step.collabPolls' },
|
||||
list_collab_notes: { stepKey: 'assistant.step.collabNotes' },
|
||||
},
|
||||
entities: {
|
||||
collab_message: { Icon: MessageCircle, openLabelKey: 'assistant.card.collab.openChat', open: () => CHAT_TARGET },
|
||||
collab_poll: { Icon: BarChart3, openLabelKey: 'assistant.card.collab.openPolls', open: () => POLLS_TARGET },
|
||||
collab_note: { Icon: StickyNote, openLabelKey: 'assistant.card.collab.openNotes', open: () => NOTES_TARGET },
|
||||
},
|
||||
cards: {
|
||||
collabChat: {},
|
||||
collabPolls: {},
|
||||
collabNotes: {},
|
||||
collabChat: { entity: () => 'collab_message' },
|
||||
collabPolls: { entity: () => 'collab_poll', yieldsToChange: readServedChange },
|
||||
collabNotes: { entity: () => 'collab_note', yieldsToChange: readServedChange },
|
||||
},
|
||||
} satisfies PackLook<'collabChat' | 'collabPolls' | 'collabNotes'>;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { type CardView, PENDING_VIEW } from '../cardTypes';
|
||||
import type { CardView } from '../cardTypes';
|
||||
import CollabChatBlock, { CollabChatBody } from './CollabChatCard';
|
||||
import CollabNotesBlock, { CollabNoteFacts, CollabNotesBody } from './CollabNotesCard';
|
||||
import CollabPollsBlock, { CollabPollsBody } from './CollabPollsCard';
|
||||
|
||||
/**
|
||||
* How the kinds of the trip chat, polls and notes draw in the chat; each one
|
||||
* draws nothing until its cards are built. This file only maps each kind to its
|
||||
* view: the components live in sibling files, since a file that exports
|
||||
* anything besides components breaks fast refresh, and lint:warnings holds
|
||||
* react-refresh/only-export-components at zero for a new file.
|
||||
* How the kinds of the trip chat, polls and notes draw in the chat: an
|
||||
* answer as the chat, the polls or the notes the way the Collab tab shows
|
||||
* them; a change row as the one bubble, poll or note it will leave, under
|
||||
* its faint line, the card naming what the row would otherwise name in bold.
|
||||
* A note change states what it changes on the faint line. This file only
|
||||
* maps each kind to its view: the components live in sibling files, since a
|
||||
* file that exports anything besides components breaks fast refresh, and
|
||||
* lint:warnings holds react-refresh/only-export-components at zero for a new
|
||||
* file.
|
||||
*/
|
||||
export const COLLAB_VIEWS = {
|
||||
collabChat: PENDING_VIEW,
|
||||
collabPolls: PENDING_VIEW,
|
||||
collabNotes: PENDING_VIEW,
|
||||
collabChat: { Block: CollabChatBlock, Body: CollabChatBody, ownsSubject: true },
|
||||
collabPolls: { Block: CollabPollsBlock, Body: CollabPollsBody, ownsSubject: true },
|
||||
collabNotes: { Block: CollabNotesBlock, Body: CollabNotesBody, Facts: CollabNoteFacts, ownsSubject: true },
|
||||
} satisfies { [K in 'collabChat' | 'collabPolls' | 'collabNotes']: CardView<K> };
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { bubbleRadius } from './collabModel';
|
||||
|
||||
/**
|
||||
* The pieces a message of the trip chat is drawn with, shared by the Collab
|
||||
* tab and the assistant chat's cards so a message looks the same in both:
|
||||
* the bubble with its tail and the quote it answers, the face beside it, the
|
||||
* name over a group, the day between groups, the floating reactions and the
|
||||
* time under the last message. Each piece only draws; what it does on a
|
||||
* click or a hover stays with the caller.
|
||||
*/
|
||||
|
||||
/** A face beside the last message of a group: the picture, else the first letter. */
|
||||
export function ChatAvatar({ name, src, size = 28 }: { name: string; src: string | null; size?: number }) {
|
||||
if (src) {
|
||||
return <img src={src} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover' }} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{(name || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Today", "Yesterday" or a date between two groups of messages. */
|
||||
export function ChatDateSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
background: 'var(--bg-card)',
|
||||
padding: '3px 12px',
|
||||
borderRadius: 99,
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name over the first message of someone else's group. */
|
||||
export function ChatSenderName({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
marginBottom: 2,
|
||||
paddingInlineStart: 4,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One to three emoji alone, drawn large without a bubble. */
|
||||
export function ChatBigEmoji({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bubble: the user's own in the accent with its tail on their side, the
|
||||
* others as cards on the tinted panel; the tail only on the last message of
|
||||
* a group. A reply carries the quote of the message it answers on top.
|
||||
*/
|
||||
export function ChatBubble({
|
||||
own,
|
||||
lastInGroup,
|
||||
reply,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
own: boolean;
|
||||
lastInGroup: boolean;
|
||||
reply: { name: string; text: string } | null;
|
||||
/** The message's words. */
|
||||
text: ReactNode;
|
||||
/** What comes under the words: images, a link preview. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// Own messages in the accent, the others as cards on the tinted panel.
|
||||
background: own ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
border: own ? 'none' : '1px solid var(--border-faint)',
|
||||
...bubbleRadius(own, lastInGroup),
|
||||
padding: reply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
lineHeight: 1.4,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{reply && (
|
||||
<div
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
marginBottom: 4,
|
||||
borderRadius: 12,
|
||||
background: own ? 'color-mix(in srgb, var(--accent-text) 15%, transparent)' : 'var(--bg-secondary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
opacity: 0.7,
|
||||
marginBottom: 1,
|
||||
}}
|
||||
>
|
||||
{reply.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{reply.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reply ? <div style={{ padding: '0 10px 4px' }}>{text}</div> : text}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The reactions floating under a bubble, on its side, in one pill. */
|
||||
export function ChatReactions({ own, children }: { own: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 3,
|
||||
marginTop: -6,
|
||||
marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingInlineStart: own ? 0 : 8,
|
||||
paddingInlineEnd: own ? 8 : 0,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: '3px 6px',
|
||||
borderRadius: 99,
|
||||
background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The time under the last message of a group, or what stands in its place. */
|
||||
export function ChatTime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
color: 'var(--text-faint)',
|
||||
marginTop: 2,
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
/** Links read as underlined words that open nothing, as in the assistant chat's copy of a message. */
|
||||
plain?: boolean
|
||||
}
|
||||
|
||||
export function MessageText({ text }: MessageTextProps) {
|
||||
export function MessageText({ text, plain = false }: MessageTextProps) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
const result: ReactNode[] = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) result.push(part)
|
||||
if (urls[i]) result.push(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
const url = urls[i]
|
||||
if (url && plain) result.push(
|
||||
<span key={i} style={{ textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85, overflowWrap: 'anywhere' }}>
|
||||
{url}
|
||||
</span>
|
||||
)
|
||||
else if (url) result.push(
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatChatClockTime, formatChatDateSeparator, isFromAssistant, shouldShowChatDateSeparator } from './collabModel'
|
||||
import CollabAssistantBadge from './CollabAssistantBadge'
|
||||
import { ChatAvatar, ChatBigEmoji, ChatBubble, ChatDateSeparator, ChatReactions, ChatSenderName, ChatTime } from './CollabChatBubble'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { CollabChatAttachment } from './CollabChatAttachment'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
@@ -52,13 +53,7 @@ export function ChatMessages(props: any) {
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatChatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDate && <ChatDateSeparator label={formatChatDateSeparator(msg.created_at, t)} />}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ display: 'inline-flex', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
<span>{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'}</span>
|
||||
@@ -69,27 +64,10 @@ export function ChatMessages(props: any) {
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const tail = isLastInGroup ? 4 : 18
|
||||
const br = {
|
||||
borderStartStartRadius: 18, borderStartEndRadius: 18,
|
||||
borderEndEndRadius: own ? tail : 18, borderEndStartRadius: own ? 18 : tail,
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatChatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDate && <ChatDateSeparator label={formatChatDateSeparator(msg.created_at, t)} />}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
@@ -102,29 +80,13 @@ export function ChatMessages(props: any) {
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showAvatar && <ChatAvatar name={msg.username} src={msg.user_avatar || null} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingInlineStart: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
{!own && isNewGroup && <ChatSenderName>{msg.username}</ChatSenderName>}
|
||||
{isFromAssistant(msg) && (
|
||||
<span className="mb-0.5 ps-1">
|
||||
<CollabAssistantBadge name={msg.username} />
|
||||
@@ -149,41 +111,19 @@ export function ChatMessages(props: any) {
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
<ChatBigEmoji>{msg.text}</ChatBigEmoji>
|
||||
) : (
|
||||
<div style={{
|
||||
// Own messages in the accent, the others as cards on the tinted panel.
|
||||
background: own ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
border: own ? 'none' : '1px solid var(--border-faint)',
|
||||
...br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'color-mix(in srgb, var(--accent-text) 15%, transparent)' : 'var(--bg-secondary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
<ChatBubble
|
||||
own={own}
|
||||
lastInGroup={isLastInGroup}
|
||||
reply={hasReply ? { name: msg.reply_username || '', text: (msg.reply_text || '').slice(0, 80) } : null}
|
||||
text={<MessageText text={msg.text} />}
|
||||
>
|
||||
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
</ChatBubble>
|
||||
)}
|
||||
|
||||
{/* Hover actions: above the reactions of the message before, which reach into this row */}
|
||||
@@ -228,30 +168,15 @@ export function ChatMessages(props: any) {
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingInlineStart: own ? 0 : 8, paddingInlineEnd: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChatReactions own={own}>
|
||||
{msg.reactions.map(r => (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
))}
|
||||
</ChatReactions>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatChatClockTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
{isLastInGroup && <ChatTime>{formatChatClockTime(msg.created_at, is12h)}</ChatTime>}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Pin } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { fs } from '../Planner/bookings/bookingParts';
|
||||
import { tintOf } from '../Planner/planParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* The head band of a shared note, tinted by its category's colour: the
|
||||
* category as a dot (its name in the tooltip), the title, a pin when it is
|
||||
* pinned and whatever the caller puts at the end (the note card's link, menu
|
||||
* and author). The Collab tab and the assistant chat's note cards draw it.
|
||||
*/
|
||||
export function NoteHeadBand({
|
||||
title,
|
||||
color,
|
||||
category,
|
||||
pinned,
|
||||
bordered,
|
||||
trailing,
|
||||
titleClassName = '',
|
||||
}: {
|
||||
title: ReactNode;
|
||||
/** The category's colour, already checked. */
|
||||
color: string;
|
||||
category: string | null;
|
||||
pinned: boolean;
|
||||
/** A line under the band, when a body follows. */
|
||||
bordered: boolean;
|
||||
trailing?: ReactNode;
|
||||
/** Extra classes for the title (struck through for a note about to go). */
|
||||
titleClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 py-1.5 ps-3 pe-1.5 ${bordered ? 'border-b border-edge-faint' : ''}`}
|
||||
style={{ background: tintOf(color) }}
|
||||
>
|
||||
{/* The category is only its colour here; the name is in the tooltip and the filter above. */}
|
||||
{category && (
|
||||
<Tooltip label={category}>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={category}
|
||||
data-testid="note-category-dot"
|
||||
className="h-2.5 w-2.5 flex-none rounded-full"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className={`min-w-0 flex-1 truncate font-bold text-content ${titleClassName}`} style={fs(13, 'body')}>
|
||||
{title}
|
||||
</span>
|
||||
{pinned && <Pin size={12} strokeWidth={2.2} className="flex-none" style={{ color }} />}
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
import { Eyebrow, fs } from '../Planner/bookings/bookingParts'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||
import { NoteHeadBand } from './CollabNoteHead'
|
||||
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -77,15 +78,7 @@ export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, on
|
||||
// A pinned note keeps its category colour on the frame, the way a selected booking card does.
|
||||
style={{ borderColor: note.pinned ? `color-mix(in srgb, ${color} 45%, transparent)` : 'var(--border-faint)' }}
|
||||
>
|
||||
<div className={`flex items-center gap-2 py-1.5 ps-3 pe-1.5 ${hasBody ? 'border-b border-edge-faint' : ''}`} style={{ background: `color-mix(in srgb, ${color} 11%, transparent)` }}>
|
||||
{/* The category is only its colour here; the name is in the tooltip and the filter above. */}
|
||||
{note.category && (
|
||||
<Tooltip label={note.category}>
|
||||
<span role="img" aria-label={note.category} data-testid="note-category-dot" className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: color }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{note.title}</span>
|
||||
{!!note.pinned && <Pin size={12} strokeWidth={2.2} className="flex-none" style={{ color }} />}
|
||||
<NoteHeadBand title={note.title} color={color} category={note.category || null} pinned={!!note.pinned} bordered={hasBody} trailing={<>
|
||||
{/* The link rides in the title row as one round button; the host is in its tooltip. */}
|
||||
{note.website && (
|
||||
<Tooltip label={websiteHref ? linkHost(websiteHref) : note.website}>
|
||||
@@ -111,7 +104,7 @@ export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, on
|
||||
<Tooltip label={author.username}>
|
||||
<span role="img" aria-label={author.username} className="flex flex-none"><UserAvatar user={author} size={20} /></span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>} />
|
||||
|
||||
{hasBody && (
|
||||
<div className="flex flex-1 flex-col gap-2 px-3 pb-2.5 pt-2">
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* FE-COLLAB-PARTS-001 to FE-COLLAB-PARTS-005
|
||||
*
|
||||
* The pieces the Collab tab and the assistant chat's cards draw a message, a
|
||||
* poll option and a note's head band with: the bubble's sides, tail and
|
||||
* quote, the face or its letter, the option as a button or a row with its
|
||||
* results and a held vote, the tinted band with its dot and pin, and links
|
||||
* that read as words where they must not open.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatAvatar, ChatBubble, ChatDateSeparator, ChatReactions, ChatTime } from './CollabChatBubble';
|
||||
import { MessageText } from './CollabChatMessageText';
|
||||
import { NoteHeadBand } from './CollabNoteHead';
|
||||
import { PollChip, PollOptionRow } from './CollabPollOption';
|
||||
|
||||
describe('the Collab pieces', () => {
|
||||
it('FE-COLLAB-PARTS-001: a bubble takes its side colour, its tail on the last of a run and the quote it answers', () => {
|
||||
const { rerender } = render(<ChatBubble own lastInGroup reply={null} text="mine" />);
|
||||
const own = screen.getByText('mine');
|
||||
expect(own.getAttribute('style')).toContain('background: var(--accent)');
|
||||
expect(own.getAttribute('style')).toContain('border-end-end-radius: 4px');
|
||||
rerender(<ChatBubble own={false} lastInGroup={false} reply={{ name: 'Anna', text: 'Train?' }} text="yours" />);
|
||||
const quoted = screen.getByText('yours');
|
||||
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
|
||||
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||
expect(screen.getByText('Train?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
|
||||
const { container, rerender } = render(<ChatAvatar name="jon" src={null} size={24} />);
|
||||
expect(screen.getByText('J').getAttribute('style')).toContain('width: 24px');
|
||||
rerender(<ChatAvatar name="jon" src="/uploads/avatars/jon.png" />);
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe('/uploads/avatars/jon.png');
|
||||
rerender(
|
||||
<>
|
||||
<ChatDateSeparator label="Today" />
|
||||
<ChatReactions own>
|
||||
<span>❤️</span>
|
||||
</ChatReactions>
|
||||
<ChatTime>09:12</ChatTime>
|
||||
</>
|
||||
);
|
||||
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||
expect(screen.getByText('❤️').parentElement?.parentElement?.getAttribute('style')).toContain('flex-end');
|
||||
expect(screen.getByText('09:12')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-003: links open in a new tab in the chat and read as words where they must not open', () => {
|
||||
const { container, rerender } = render(<MessageText text="see https://a.example/x now" />);
|
||||
expect(container.querySelector('a')?.getAttribute('href')).toBe('https://a.example/x');
|
||||
rerender(<MessageText text="see https://a.example/x now" plain />);
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
expect(screen.getByText('https://a.example/x').tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-004: an option votes as a button, shows as a row, and marks a held vote', () => {
|
||||
const onClick = vi.fn();
|
||||
const voters = [{ key: 7, name: 'anna', src: null }];
|
||||
const { rerender } = render(
|
||||
<PollOptionRow
|
||||
text="Pizza"
|
||||
pct={50}
|
||||
mine={false}
|
||||
winner={false}
|
||||
showResults={false}
|
||||
voters={voters}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
expect(screen.queryByText('50%')).toBeNull();
|
||||
rerender(
|
||||
<PollOptionRow
|
||||
as="div"
|
||||
text="Pizza"
|
||||
pct={50}
|
||||
mine
|
||||
winner={false}
|
||||
showResults
|
||||
voters={voters}
|
||||
pending={{ label: 'Your vote' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your vote').className).toContain('sr-only');
|
||||
expect(screen.getByText('Pizza').closest('div')?.className).toContain('border-dashed');
|
||||
render(<PollChip tone="info">New</PollChip>);
|
||||
expect(screen.getByText('New').className).toContain('text-info');
|
||||
});
|
||||
|
||||
it("FE-COLLAB-PARTS-005: a note's band shows its category as a dot, its title and its pin", () => {
|
||||
const { container } = render(
|
||||
<NoteHeadBand title="Food" color="#10b981" category="Ideas" pinned bordered trailing={<span>end</span>} />
|
||||
);
|
||||
expect(screen.getByTestId('note-category-dot').style.background).toBe('rgb(16, 185, 129)');
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('end')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-pin')).not.toBeNull();
|
||||
expect(container.firstElementChild?.className).toContain('border-b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* The pieces a poll of the trip is drawn with, shared by the Collab tab and
|
||||
* the assistant chat's poll cards: an option with its bar, its check, its
|
||||
* voters and its share, a voter's face and a fact on the poll's head band.
|
||||
* An option is a button where it can be voted for and a plain row where it
|
||||
* only shows; a vote the assistant holds marks its option with a dashed
|
||||
* frame until it is decided.
|
||||
*/
|
||||
|
||||
/** A voter as an option shows them: a name and the picture TREK serves, if any. */
|
||||
interface PollVoterFace {
|
||||
key: string | number;
|
||||
name: string;
|
||||
src: string | null;
|
||||
}
|
||||
|
||||
/** One voter's face; faces after the first overlap the one before. */
|
||||
function VoterChip({ name, src, offset }: { name: string; src: string | null; offset: boolean }) {
|
||||
return (
|
||||
<Tooltip label={name || '?'}>
|
||||
<span
|
||||
className="grid h-[18px] w-[18px] flex-none place-items-center overflow-hidden rounded-full border-[1.5px] border-surface-card bg-surface-tertiary font-bold text-content-muted"
|
||||
style={{ ...fs(7), marginInlineStart: offset ? -5 : 0 }}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
(name || '?').charAt(0).toUpperCase()
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fact about the poll on its head band, set like the type chip on a booking card. */
|
||||
export function PollChip({
|
||||
icon,
|
||||
tone = 'muted',
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
tone?: 'muted' | 'warning' | 'info';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const color = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex flex-none items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-geist font-bold uppercase tracking-[.06em] ${color}`}
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface PollOptionRowProps {
|
||||
text: string;
|
||||
/** Its share of all votes, which the bar draws. */
|
||||
pct: number;
|
||||
/** The signed-in user voted for it. */
|
||||
mine: boolean;
|
||||
/** The poll ended and it has the most votes. */
|
||||
winner: boolean;
|
||||
/** Shares and voters show once the user voted or the poll ended. */
|
||||
showResults: boolean;
|
||||
voters: readonly PollVoterFace[];
|
||||
/** A button to vote with, or a row that only shows. */
|
||||
as?: 'button' | 'div';
|
||||
/** A vote waiting for its decision: the frame is dashed, and says so to a screen reader. */
|
||||
pending?: { label: string } | null;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** One option of a poll, as the Collab tab draws it. */
|
||||
export function PollOptionRow({
|
||||
text,
|
||||
pct,
|
||||
mine,
|
||||
winner,
|
||||
showResults,
|
||||
voters,
|
||||
as = 'button',
|
||||
pending = null,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: PollOptionRowProps) {
|
||||
const frame = pending
|
||||
? 'border-dashed border-accent'
|
||||
: mine
|
||||
? 'border-accent'
|
||||
: 'border-edge-faint enabled:hover:border-edge';
|
||||
const body = (
|
||||
<>
|
||||
{/* The bar behind it: the user's choice in the accent, the winner in green. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 start-0 transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: mine
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: winner
|
||||
? 'var(--success-soft)'
|
||||
: 'var(--bg-tertiary)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`relative grid h-5 w-5 flex-none place-items-center rounded-full border-2 transition-colors ${mine ? 'border-accent bg-accent text-accent-text' : 'border-edge bg-surface-card'}`}
|
||||
>
|
||||
{mine && <Check size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
{/* Plain text, but a long or multiline option wraps instead of being clipped (#2177). */}
|
||||
<span
|
||||
className="relative z-[1] text-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.35,
|
||||
...fs(13, 'body'),
|
||||
fontWeight: mine || winner ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{voters.length > 0 && showResults && (
|
||||
<span className="relative z-[1] flex">
|
||||
{voters.slice(0, 3).map((voter, index) => (
|
||||
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{pending && <span className="sr-only">{pending.label}</span>}
|
||||
{showResults && (
|
||||
<span
|
||||
className={`relative z-[1] min-w-[32px] text-end font-geist font-bold tabular-nums ${mine ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const look = `relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-start transition-colors disabled:cursor-default ${frame}`;
|
||||
if (as === 'div') return <div className={look}>{body}</div>;
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled} className={look}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useCallback, useId } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { Plus, Trash2, X, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { RoundAction } from '../Planner/bookings/bookingParts'
|
||||
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
|
||||
import { PollChip, PollOptionRow } from './CollabPollOption'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, TEXTAREA } from '../shared/dialogParts'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -19,12 +20,6 @@ import type { CollabPollCreateRequest } from '@trek/shared'
|
||||
import { useCollabPolls } from './useCollabPolls'
|
||||
import { hasUserVoted, isPollActive, pollMaxVoteCount, splitPolls, totalPollVotes, type CollabPollData } from './collabModel'
|
||||
|
||||
interface PollVoter {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
// Block styling for the markdown question (#2177). Tailwind's preflight resets
|
||||
@@ -156,33 +151,6 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Voter Chip ───────────────────────────────────────────────────────────────
|
||||
interface VoterChipProps {
|
||||
voter: PollVoter
|
||||
offset: boolean
|
||||
}
|
||||
|
||||
function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
return (
|
||||
<Tooltip label={voter.username || '?'}>
|
||||
<span className="grid h-[18px] w-[18px] flex-none place-items-center overflow-hidden rounded-full border-[1.5px] border-surface-card bg-surface-tertiary font-bold text-content-muted"
|
||||
style={{ ...fs(7), marginInlineStart: offset ? -5 : 0 }}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} className="h-full w-full object-cover" /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** A fact about the poll on its head band, set like the type chip on a booking card. */
|
||||
function PollChip({ icon, tone = 'muted', children }: { icon?: React.ReactNode; tone?: 'muted' | 'warning'; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-flex flex-none items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-geist font-bold uppercase tracking-[.06em] ${tone === 'warning' ? 'text-warning' : 'text-content-muted'}`} style={fs(9.5)}>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Poll Card ────────────────────────────────────────────────────────────────
|
||||
interface PollCardProps {
|
||||
poll: CollabPollData
|
||||
@@ -238,48 +206,17 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const isWinner = isClosed && count > 0 && count === topCount
|
||||
|
||||
return (
|
||||
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
<PollOptionRow
|
||||
key={idx}
|
||||
text={typeof opt === 'string' ? opt : opt.text}
|
||||
pct={pct}
|
||||
mine={myVote}
|
||||
winner={isWinner}
|
||||
showResults={hasVoted || isClosed}
|
||||
voters={(opt.voters || []).map((v, vi) => ({ key: v.user_id || vi, name: v.username || '?', src: v.avatar_url }))}
|
||||
disabled={isClosed}
|
||||
className={`relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-start transition-colors disabled:cursor-default ${myVote ? 'border-accent' : 'border-edge-faint enabled:hover:border-edge'}`}
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<span aria-hidden="true" className="absolute inset-y-0 left-0 transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: myVote ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : isWinner ? 'var(--success-soft)' : 'var(--bg-tertiary)',
|
||||
}} />
|
||||
|
||||
{/* Check circle */}
|
||||
<span className={`relative grid h-5 w-5 flex-none place-items-center rounded-full border-2 transition-colors ${myVote ? 'border-accent bg-accent text-accent-text' : 'border-edge bg-surface-card'}`}>
|
||||
{myVote && <Check size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
|
||||
{/* Label — options stay plain text, but long or multiline ones
|
||||
must wrap instead of being clipped by the button's
|
||||
overflow:hidden (#2177) */}
|
||||
<span className="relative z-[1] text-content" style={{
|
||||
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
|
||||
lineHeight: 1.35, ...fs(13, 'body'), fontWeight: myVote || isWinner ? 600 : 400,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
</span>
|
||||
|
||||
{/* Voter avatars */}
|
||||
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
|
||||
<span className="relative z-[1] flex">
|
||||
{(opt.voters || []).slice(0, 3).map((v, vi) => (
|
||||
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Percentage */}
|
||||
{(hasVoted || isClosed) && (
|
||||
<span className={`relative z-[1] min-w-[32px] text-end font-geist font-bold tabular-nums ${myVote ? 'text-content' : 'text-content-muted'}`} style={fs(12, 'body')}>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
onClick={() => onVote(poll.id, idx)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,20 @@ export const OTHER_BUBBLE_RADIUS = {
|
||||
borderEndStartRadius: 16,
|
||||
} as const;
|
||||
|
||||
/** The corners of a desktop chat bubble: round all over, with the tail (a tight
|
||||
* corner on the sender's side) only on the last message of a group. Logical
|
||||
* corners, so the tail follows the row in a right-to-left language. The Collab
|
||||
* tab and the assistant's chat card draw with it. */
|
||||
export function bubbleRadius(own: boolean, lastInGroup: boolean) {
|
||||
const tail = lastInGroup ? 4 : 18;
|
||||
return {
|
||||
borderStartStartRadius: 18,
|
||||
borderStartEndRadius: 18,
|
||||
borderEndEndRadius: own ? tail : 18,
|
||||
borderEndStartRadius: own ? 18 : tail,
|
||||
};
|
||||
}
|
||||
|
||||
// SQLite stores UTC without a 'Z' suffix — append one so JS parses it as UTC
|
||||
// rather than local time.
|
||||
export function parseUtcDate(iso: string): Date {
|
||||
|
||||
Reference in New Issue
Block a user