@tuturuuu/ui 0.39.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.39.1](https://github.com/tutur3u/platform/compare/ui-v0.39.0...ui-v0.39.1) (2026-10-17)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** reconcile production history before Contacts rollout ([dc017a2](https://github.com/tutur3u/platform/commit/dc017a227de6f284268b34690198972c06c52434)) ([#6241](https://github.com/tutur3u/platform/issues/6241)) ([66654a5](https://github.com/tutur3u/platform/commit/66654a5d7ad441a93070d1502f267d6d4e08a6cd))
9
+
3
10
  ## [0.39.0](https://github.com/tutur3u/platform/compare/ui-v0.38.0...ui-v0.39.0) (2026-10-09)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.39.0",
3
+ "version": "0.39.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -87,14 +87,14 @@
87
87
  "@tiptap/pm": "3.31.3",
88
88
  "@tiptap/react": "3.31.3",
89
89
  "@tiptap/starter-kit": "3.31.3",
90
- "@tuturuuu/ai": "0.18.0",
90
+ "@tuturuuu/ai": "0.18.1",
91
91
  "@tuturuuu/apis": "0.13.0",
92
92
  "@tuturuuu/hooks": "0.1.2",
93
93
  "@tuturuuu/icons": "0.1.1",
94
- "@tuturuuu/internal-api": "0.50.0",
95
- "@tuturuuu/realtime": "0.10.1",
96
- "@tuturuuu/supabase": "0.5.2",
97
- "@tuturuuu/utils": "0.35.0",
94
+ "@tuturuuu/internal-api": "0.51.0",
95
+ "@tuturuuu/realtime": "0.10.2",
96
+ "@tuturuuu/supabase": "0.6.0",
97
+ "@tuturuuu/utils": "0.36.0",
98
98
  "@types/debug": "^4.1.13",
99
99
  "browser-image-compression": "^2.0.2",
100
100
  "class-variance-authority": "^0.7.1",
@@ -155,7 +155,7 @@
155
155
  "@tanstack/react-table": "^9.2.4",
156
156
  "@testing-library/jest-dom": "^7.0.1",
157
157
  "@testing-library/react": "^16.3.3",
158
- "@tuturuuu/types": "0.34.0",
158
+ "@tuturuuu/types": "0.35.0",
159
159
  "@tuturuuu/typescript-config": "0.1.2",
160
160
  "@types/html2canvas": "^1.0.0",
161
161
  "@types/lodash": "^4.17.25",
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+ import { UserRound } from '@tuturuuu/icons';
3
+ import { DropdownMenuItem } from '@tuturuuu/ui/dropdown-menu';
4
+
5
+ export function PublicProfileMenuItem({
6
+ href,
7
+ label,
8
+ }: {
9
+ href: string;
10
+ label: string;
11
+ }) {
12
+ return (
13
+ <DropdownMenuItem asChild>
14
+ <a
15
+ href={href}
16
+ target="_blank"
17
+ rel="noopener noreferrer"
18
+ className="cursor-pointer"
19
+ >
20
+ <UserRound className="h-4 w-4 text-muted-foreground" />
21
+ <span>{label}</span>
22
+ </a>
23
+ </DropdownMenuItem>
24
+ );
25
+ }
@@ -0,0 +1,61 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { expect, it, vi } from 'vitest';
4
+ import { SatelliteUserMenuItems } from './satellite-user-menu-items';
5
+
6
+ vi.mock('@tuturuuu/ui/dropdown-menu', () => {
7
+ const Container = ({ children }: { children: ReactNode }) => <>{children}</>;
8
+ return Object.fromEntries(
9
+ [
10
+ 'DropdownMenuGroup',
11
+ 'DropdownMenuItem',
12
+ 'DropdownMenuPortal',
13
+ 'DropdownMenuSeparator',
14
+ 'DropdownMenuSub',
15
+ 'DropdownMenuSubContent',
16
+ 'DropdownMenuSubTrigger',
17
+ ].map((key) => [key, Container])
18
+ );
19
+ });
20
+ const props: ComponentProps<typeof SatelliteUserMenuItems> = {
21
+ centralUrl: 'https://tuturuuu.com',
22
+ t: (key) => key,
23
+ languageItems: null,
24
+ themeItems: null,
25
+ accountItems: null,
26
+ signedIn: true,
27
+ onReport: vi.fn(),
28
+ onLogout: vi.fn(),
29
+ };
30
+ it('renders a labelled public profile link separately from private account settings', () => {
31
+ const html = renderToStaticMarkup(
32
+ <SatelliteUserMenuItems
33
+ {...props}
34
+ publicProfile={{
35
+ href: 'https://tuturuuu.com/u/creator',
36
+ label: 'Public profile',
37
+ }}
38
+ />
39
+ );
40
+ expect(html).toContain('href="https://tuturuuu.com/u/creator"');
41
+ expect(html).toContain('Public profile');
42
+ expect(html).toContain('rel="noopener noreferrer"');
43
+ expect(html).toContain('common.dashboard');
44
+ });
45
+ it('does not invent a profile link for users without a username', () => {
46
+ const html = renderToStaticMarkup(<SatelliteUserMenuItems {...props} />);
47
+ expect(html).not.toContain('/u/');
48
+ });
49
+ it('does not expose a profile link in a signed-out menu even if an adapter supplies one', () => {
50
+ const html = renderToStaticMarkup(
51
+ <SatelliteUserMenuItems
52
+ {...props}
53
+ signedIn={false}
54
+ publicProfile={{
55
+ href: 'https://tuturuuu.com/u/creator',
56
+ label: 'Public profile',
57
+ }}
58
+ />
59
+ );
60
+ expect(html).not.toContain('/u/creator');
61
+ });
@@ -22,6 +22,9 @@ import {
22
22
  DropdownMenuSubTrigger,
23
23
  } from '@tuturuuu/ui/dropdown-menu';
24
24
  import type { ReactNode } from 'react';
25
+ import { PublicProfileMenuItem } from './public-profile-menu-item';
26
+
27
+ export { PublicProfileMenuItem } from './public-profile-menu-item';
25
28
 
26
29
  export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover' | 'hidden';
27
30
 
@@ -35,6 +38,7 @@ export function SatelliteUserMenuItems({
35
38
  accountItems,
36
39
  workspaceSelect,
37
40
  signedIn,
41
+ publicProfile,
38
42
  onReport,
39
43
  onLogout,
40
44
  }: {
@@ -61,6 +65,7 @@ export function SatelliteUserMenuItems({
61
65
  accountItems: ReactNode;
62
66
  workspaceSelect?: ReactNode;
63
67
  signedIn: boolean;
68
+ publicProfile?: { href: string; label: string };
64
69
  onReport: () => void;
65
70
  onLogout: () => void;
66
71
  }) {
@@ -75,6 +80,9 @@ export function SatelliteUserMenuItems({
75
80
  </>
76
81
  ) : null}
77
82
  <DropdownMenuGroup>
83
+ {signedIn && publicProfile && (
84
+ <PublicProfileMenuItem {...publicProfile} />
85
+ )}
78
86
  <DropdownMenuItem asChild>
79
87
  <a
80
88
  href={centralUrl}
@@ -342,6 +342,57 @@ describe('actual invoice card PNG export account and invoice lifetime', () => {
342
342
  expect(downloads).toEqual([]);
343
343
  expect(notices.success).not.toHaveBeenCalled();
344
344
  });
345
+ for (const failure of ['renderer', 'blob'] as const) {
346
+ it(`allows one same-invoice retry after a ${failure} failure`, async () => {
347
+ if (failure === 'renderer') {
348
+ renderer.mockRejectedValueOnce(new Error('Synthetic renderer failure'));
349
+ } else {
350
+ renderer.mockResolvedValueOnce({
351
+ toBlob: (callback: BlobCallback) => callback(null),
352
+ });
353
+ }
354
+ const retry = held<ReturnType<typeof canvas>>();
355
+ renderer.mockReturnValueOnce(retry.promise);
356
+ mount();
357
+ await exportPng();
358
+ await waitFor(() =>
359
+ expect(notices.error).toHaveBeenCalledWith('common.export-error')
360
+ );
361
+ expect(downloads).toEqual([]);
362
+ expect(notices.success).not.toHaveBeenCalled();
363
+ expect(URL.createObjectURL).not.toHaveBeenCalled();
364
+ await waitFor(() =>
365
+ expect(
366
+ screen.getByRole('menuitem', { name: 'invoices.download_image' })
367
+ ).not.toHaveAttribute('aria-disabled', 'true')
368
+ );
369
+
370
+ await exportPng();
371
+ await waitFor(() => expect(renderer).toHaveBeenCalledTimes(2));
372
+ const exporting = screen.getByRole('menuitem', {
373
+ name: 'ws-reports.exporting_png',
374
+ });
375
+ expect(exporting).toHaveAttribute('aria-disabled', 'true');
376
+ fireEvent.click(exporting);
377
+ expect(renderer).toHaveBeenCalledTimes(2);
378
+ expect(downloads).toEqual([]);
379
+
380
+ await act(async () => {
381
+ retry.resolve(canvas());
382
+ await retry.promise;
383
+ });
384
+ await waitFor(() =>
385
+ expect(downloads).toEqual(['invoice-synthetic_a.png'])
386
+ );
387
+ expect(notices.error).toHaveBeenCalledOnce();
388
+ expect(notices.success).toHaveBeenCalledOnce();
389
+ expect(URL.createObjectURL).toHaveBeenCalledOnce();
390
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic-png');
391
+ expect(
392
+ screen.getByRole('menuitem', { name: 'invoices.download_image' })
393
+ ).not.toHaveAttribute('aria-disabled', 'true');
394
+ });
395
+ }
345
396
  for (const boundary of [
346
397
  'missing-provider',
347
398
  'missing-actor',
@@ -0,0 +1,116 @@
1
+ // @vitest-environment jsdom
2
+ import { act, type ComponentProps } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
5
+ import { PublicLinkButton } from './public-link-button';
6
+
7
+ vi.mock('./button', () => ({
8
+ Button: ({
9
+ variant: _variant,
10
+ ...props
11
+ }: ComponentProps<'button'> & { variant?: string }) => <button {...props} />,
12
+ }));
13
+ vi.mock('./input', () => ({
14
+ Input: (props: ComponentProps<'input'>) => <input {...props} />,
15
+ }));
16
+ const container = document.createElement('div');
17
+ document.body.append(container);
18
+ let root = createRoot(container);
19
+ const copy = vi.fn(),
20
+ url = 'https://tuturuuu.com/u/creator';
21
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
22
+ beforeEach(() => {
23
+ Object.defineProperty(navigator, 'clipboard', {
24
+ configurable: true,
25
+ value: { writeText: copy },
26
+ });
27
+ copy.mockResolvedValue(undefined);
28
+ });
29
+ afterEach(async () => {
30
+ await act(() => root.unmount());
31
+ root = createRoot(container);
32
+ vi.clearAllMocks();
33
+ });
34
+ async function render(link: string | null = url) {
35
+ await act(() =>
36
+ root.render(
37
+ <PublicLinkButton
38
+ url={link}
39
+ label="Copy link"
40
+ copiedLabel="Copied"
41
+ errorLabel="Failed"
42
+ manualCopyLabel="Manual link"
43
+ />
44
+ )
45
+ );
46
+ }
47
+ async function click() {
48
+ await act(() => container.querySelector('button')!.click());
49
+ }
50
+ it('copies only after an explicit click and announces success', async () => {
51
+ await render();
52
+ expect(copy).not.toHaveBeenCalled();
53
+ expect(container.querySelector('input')).toBeNull();
54
+ await click();
55
+ expect(copy).toHaveBeenCalledWith(url);
56
+ expect(container.querySelector('[role=status]')!.textContent).toBe('Copied');
57
+ });
58
+ it('excludes invalid/private URLs entirely', async () => {
59
+ await render('https://tuturuuu.com/private/settings');
60
+ expect(container.querySelector('button')).toBeNull();
61
+ expect(copy).not.toHaveBeenCalled();
62
+ });
63
+ it('supports manual selection and retry after a clipboard denial', async () => {
64
+ await render();
65
+ copy.mockRejectedValueOnce(new Error('Denied'));
66
+ await click();
67
+ expect(container.querySelector('[role=alert]')!.textContent).toBe('Failed');
68
+ const input = container.querySelector('input')!;
69
+ expect(input.value).toBe(url);
70
+ expect(input.readOnly).toBe(true);
71
+ await act(() => input.focus());
72
+ expect(input.selectionEnd).toBe(url.length);
73
+ await click();
74
+ expect(container.querySelector('input')).toBeNull();
75
+ expect(container.querySelector('[role=status]')!.textContent).toBe('Copied');
76
+ });
77
+ it('provides manual copy when Clipboard API is unavailable', async () => {
78
+ Object.defineProperty(navigator, 'clipboard', {
79
+ configurable: true,
80
+ value: undefined,
81
+ });
82
+ await render();
83
+ await click();
84
+ expect(container.querySelector('input')!.value).toBe(url);
85
+ });
86
+ it('fences rapid duplicate clicks and suppresses stale success after URL replacement', async () => {
87
+ let finish!: () => void;
88
+ copy.mockReturnValue(
89
+ new Promise<void>((resolve) => {
90
+ finish = resolve;
91
+ })
92
+ );
93
+ await render();
94
+ await act(() => {
95
+ container.querySelector('button')!.click();
96
+ container.querySelector('button')!.click();
97
+ });
98
+ expect(copy).toHaveBeenCalledTimes(1);
99
+ await render('https://tuturuuu.com/u/other');
100
+ await act(() => finish());
101
+ expect(container.querySelector('[role=status]')).toBeNull();
102
+ expect(container.querySelector('input')).toBeNull();
103
+ });
104
+ it('suppresses stale failure after unmount', async () => {
105
+ let fail!: (error: Error) => void;
106
+ copy.mockReturnValue(
107
+ new Promise<void>((_, reject) => {
108
+ fail = reject;
109
+ })
110
+ );
111
+ await render();
112
+ await act(() => container.querySelector('button')!.click());
113
+ await act(() => root.render(null));
114
+ await act(() => fail(new Error('Denied')));
115
+ expect(container.innerHTML).toBe('');
116
+ });
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+ import { safePublicContentLink } from '@tuturuuu/utils/public-content-link';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from './button';
5
+ import { Input } from './input';
6
+
7
+ type Props = {
8
+ url: string | null;
9
+ label: string;
10
+ copiedLabel: string;
11
+ errorLabel: string;
12
+ manualCopyLabel: string;
13
+ };
14
+ export function PublicLinkButton(props: Props) {
15
+ const url = safePublicContentLink(props.url);
16
+ return url ? <CopyButton key={url} {...props} url={url} /> : null;
17
+ }
18
+ function CopyButton({
19
+ url,
20
+ label,
21
+ copiedLabel,
22
+ errorLabel,
23
+ manualCopyLabel,
24
+ }: Props & { url: string }) {
25
+ const [status, setStatus] = useState<
26
+ 'idle' | 'pending' | 'copied' | 'failed'
27
+ >('idle');
28
+ const active = useRef(true),
29
+ busy = useRef(false);
30
+ useEffect(() => {
31
+ active.current = true;
32
+ return () => {
33
+ active.current = false;
34
+ };
35
+ }, []);
36
+ return (
37
+ <div className="space-y-2">
38
+ <Button
39
+ variant="outline"
40
+ disabled={status === 'pending'}
41
+ onClick={async () => {
42
+ if (busy.current) return;
43
+ busy.current = true;
44
+ setStatus('pending');
45
+ try {
46
+ if (!navigator.clipboard?.writeText)
47
+ throw new Error('Clipboard unavailable');
48
+ await navigator.clipboard.writeText(url);
49
+ if (active.current) setStatus('copied');
50
+ } catch {
51
+ if (active.current) setStatus('failed');
52
+ } finally {
53
+ busy.current = false;
54
+ }
55
+ }}
56
+ >
57
+ {label}
58
+ </Button>
59
+ {status === 'copied' && (
60
+ <p role="status" className="text-muted-foreground text-sm">
61
+ {copiedLabel}
62
+ </p>
63
+ )}
64
+ {status === 'failed' && (
65
+ <>
66
+ <p role="alert" className="text-sm">
67
+ {errorLabel}
68
+ </p>
69
+ <label className="block space-y-1 text-sm">
70
+ {manualCopyLabel}
71
+ <Input
72
+ readOnly
73
+ value={url}
74
+ onFocus={(event) => event.target.select()}
75
+ />
76
+ </label>
77
+ </>
78
+ )}
79
+ </div>
80
+ );
81
+ }