@tuturuuu/ui 0.37.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +95 -359
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/resizable.tsx +2 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +8 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +43 -44
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-write-payload.ts +24 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +38 -72
- package/src/hooks/use-calendar.tsx +21 -45
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
- package/src/hooks/use-yjs-collaboration.ts +45 -27
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { type ReactNode, StrictMode } from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { useYjsCollaboration } from '../use-yjs-collaboration';
|
|
5
|
+
|
|
6
|
+
const instances = vi.hoisted(
|
|
7
|
+
() =>
|
|
8
|
+
[] as {
|
|
9
|
+
id: number;
|
|
10
|
+
destroyed: boolean;
|
|
11
|
+
emit: (event: string, value: unknown) => void;
|
|
12
|
+
}[]
|
|
13
|
+
);
|
|
14
|
+
vi.mock('@tuturuuu/supabase/next/client', () => ({ createClient: () => ({}) }));
|
|
15
|
+
vi.mock('@tuturuuu/ui/hooks/supabase-provider', () => ({
|
|
16
|
+
default: class {
|
|
17
|
+
id: number;
|
|
18
|
+
destroyed = false;
|
|
19
|
+
awareness: { destroy: () => void };
|
|
20
|
+
private listeners = new Map<string, (value: unknown) => void>();
|
|
21
|
+
constructor(
|
|
22
|
+
private doc: { clientID: number; destroy: () => void },
|
|
23
|
+
_client: unknown,
|
|
24
|
+
config: { awareness: { destroy: () => void } }
|
|
25
|
+
) {
|
|
26
|
+
this.id = doc.clientID;
|
|
27
|
+
this.awareness = config.awareness;
|
|
28
|
+
instances.push(this);
|
|
29
|
+
}
|
|
30
|
+
on(event: string, callback: (value: unknown) => void) {
|
|
31
|
+
this.listeners.set(event, callback);
|
|
32
|
+
}
|
|
33
|
+
emit(event: string, value: unknown) {
|
|
34
|
+
this.listeners.get(event)?.(value);
|
|
35
|
+
}
|
|
36
|
+
destroy() {
|
|
37
|
+
this.destroyed = true;
|
|
38
|
+
this.awareness.destroy();
|
|
39
|
+
this.doc.destroy();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
const user = { id: 'actor', name: 'Ada', color: '#123456' };
|
|
44
|
+
const config = {
|
|
45
|
+
channel: 'task-editor-one',
|
|
46
|
+
tableName: 'tasks',
|
|
47
|
+
columnName: 'description_yjs_state',
|
|
48
|
+
id: 'one',
|
|
49
|
+
user,
|
|
50
|
+
};
|
|
51
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
52
|
+
<StrictMode>{children}</StrictMode>
|
|
53
|
+
);
|
|
54
|
+
describe('collaboration hook lifecycle', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
instances.length = 0;
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
it('does not start awareness before authentication and reuses StrictMode providers', async () => {
|
|
63
|
+
const { result, rerender, unmount } = renderHook(
|
|
64
|
+
(actor) => useYjsCollaboration({ ...config, user: actor }),
|
|
65
|
+
{ wrapper, initialProps: null as typeof user | null }
|
|
66
|
+
);
|
|
67
|
+
expect(result.current.doc).toBeNull();
|
|
68
|
+
expect(result.current.awareness).toBeNull();
|
|
69
|
+
expect(instances).toHaveLength(0);
|
|
70
|
+
rerender(user);
|
|
71
|
+
expect(instances).toHaveLength(1);
|
|
72
|
+
const provider = result.current.provider;
|
|
73
|
+
expect(provider).not.toBeNull();
|
|
74
|
+
rerender({ ...user, name: 'Ada updated' });
|
|
75
|
+
expect(result.current.provider).toBe(provider);
|
|
76
|
+
expect(result.current.awareness?.getLocalState()?.user.name).toBe(
|
|
77
|
+
'Ada updated'
|
|
78
|
+
);
|
|
79
|
+
unmount();
|
|
80
|
+
await act(() => vi.advanceTimersByTimeAsync(100));
|
|
81
|
+
expect(instances[0]?.destroyed).toBe(true);
|
|
82
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
it('isolates account/resource switches and ignores late callbacks from old providers', async () => {
|
|
85
|
+
const { result, rerender, unmount } = renderHook(
|
|
86
|
+
(id) =>
|
|
87
|
+
useYjsCollaboration({ ...config, id, channel: `task-editor-${id}` }),
|
|
88
|
+
{ wrapper, initialProps: 'one' }
|
|
89
|
+
);
|
|
90
|
+
const first = instances[0]!;
|
|
91
|
+
const firstDoc = result.current.doc;
|
|
92
|
+
rerender('two');
|
|
93
|
+
expect(instances).toHaveLength(2);
|
|
94
|
+
expect(first.destroyed).toBe(true);
|
|
95
|
+
expect(result.current.doc).not.toBe(firstDoc);
|
|
96
|
+
const second = instances[1]!;
|
|
97
|
+
act(() => second.emit('status', [{ status: 'connected' }]));
|
|
98
|
+
expect(result.current.connected).toBe(true);
|
|
99
|
+
act(() => first.emit('disconnect', []));
|
|
100
|
+
expect(result.current.connected).toBe(true);
|
|
101
|
+
act(() => first.emit('synced', [true]));
|
|
102
|
+
expect(result.current.synced).toBe(false);
|
|
103
|
+
unmount();
|
|
104
|
+
await act(() => vi.advanceTimersByTimeAsync(100));
|
|
105
|
+
expect(second.destroyed).toBe(true);
|
|
106
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
+
import { useWorkspaceUser } from '../use-workspace-user';
|
|
5
|
+
import {
|
|
6
|
+
useWorkspaceVisibility,
|
|
7
|
+
WorkspaceVisibilityProvider,
|
|
8
|
+
} from '../use-workspace-visibility';
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ hidden: vi.fn(), profile: vi.fn() }));
|
|
11
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
12
|
+
getCurrentUserHiddenWorkspaces: mocks.hidden,
|
|
13
|
+
getCurrentUserProfile: mocks.profile,
|
|
14
|
+
updateCurrentUserHiddenWorkspace: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['private'] });
|
|
19
|
+
mocks.profile.mockResolvedValue({ id: 'A' });
|
|
20
|
+
});
|
|
21
|
+
function Probe() {
|
|
22
|
+
const hidden = useWorkspaceVisibility();
|
|
23
|
+
const user = useWorkspaceUser();
|
|
24
|
+
return (
|
|
25
|
+
<output>
|
|
26
|
+
{JSON.stringify({
|
|
27
|
+
known: hidden.known,
|
|
28
|
+
ids: hidden.hiddenIds,
|
|
29
|
+
user: user.data?.id,
|
|
30
|
+
})}
|
|
31
|
+
</output>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function fixture() {
|
|
35
|
+
return new QueryClient({
|
|
36
|
+
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
it('retains the root owner cache across same-actor route unmount and remount', async () => {
|
|
40
|
+
const client = fixture();
|
|
41
|
+
function Tree({ route }: { route: boolean }) {
|
|
42
|
+
return (
|
|
43
|
+
<QueryClientProvider client={client}>
|
|
44
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
45
|
+
{route && (
|
|
46
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
47
|
+
<Probe />
|
|
48
|
+
</WorkspaceVisibilityProvider>
|
|
49
|
+
)}
|
|
50
|
+
</WorkspaceVisibilityProvider>
|
|
51
|
+
</QueryClientProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const tree = render(<Tree route />);
|
|
55
|
+
await waitFor(() =>
|
|
56
|
+
expect(screen.getByRole('status').textContent).toContain('"known":true')
|
|
57
|
+
);
|
|
58
|
+
await waitFor(() =>
|
|
59
|
+
expect(client.getQueryData(['workspace-user', 'A'])).toMatchObject({
|
|
60
|
+
id: 'A',
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
tree.rerender(<Tree route={false} />);
|
|
64
|
+
expect(client.getQueryData(['workspace-hidden', 'A'])).toEqual(['private']);
|
|
65
|
+
tree.rerender(<Tree route />);
|
|
66
|
+
await waitFor(() =>
|
|
67
|
+
expect(screen.getByRole('status').textContent).toContain('private')
|
|
68
|
+
);
|
|
69
|
+
expect(mocks.hidden).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(client.getQueryData(['workspace-user'])).toBeUndefined();
|
|
72
|
+
tree.unmount();
|
|
73
|
+
expect(client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
|
|
74
|
+
expect(client.getQueryData(['workspace-user', 'A'])).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it('does not read profiles or private preferences without verified identity', async () => {
|
|
77
|
+
const client = fixture();
|
|
78
|
+
render(
|
|
79
|
+
<QueryClientProvider client={client}>
|
|
80
|
+
<Probe />
|
|
81
|
+
</QueryClientProvider>
|
|
82
|
+
);
|
|
83
|
+
await act(async () => {});
|
|
84
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
85
|
+
expect(mocks.hidden).not.toHaveBeenCalled();
|
|
86
|
+
expect(screen.getByRole('status').textContent).toContain('"known":false');
|
|
87
|
+
});
|
|
88
|
+
it('fails a mismatched route actor closed under a verified root', async () => {
|
|
89
|
+
const client = fixture();
|
|
90
|
+
render(
|
|
91
|
+
<QueryClientProvider client={client}>
|
|
92
|
+
<WorkspaceVisibilityProvider actorId="B">
|
|
93
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
94
|
+
<Probe />
|
|
95
|
+
</WorkspaceVisibilityProvider>
|
|
96
|
+
</WorkspaceVisibilityProvider>
|
|
97
|
+
</QueryClientProvider>
|
|
98
|
+
);
|
|
99
|
+
await act(async () => {});
|
|
100
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
101
|
+
expect(mocks.hidden).not.toHaveBeenCalled();
|
|
102
|
+
expect(screen.getByRole('status').textContent).toContain('"known":false');
|
|
103
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { validate, version } from 'uuid';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
createCalendarCreationRequests,
|
|
5
|
+
createOptimisticEventId,
|
|
6
|
+
newCalendarCreationRequestId,
|
|
7
|
+
} from './calendar-creation-request';
|
|
8
|
+
|
|
9
|
+
it('keeps explicit draft IDs on lost responses and separates manual and AI drafts', () => {
|
|
10
|
+
const ids = createCalendarCreationRequests();
|
|
11
|
+
const manual = newCalendarCreationRequestId();
|
|
12
|
+
const ai = [newCalendarCreationRequestId(), newCalendarCreationRequestId()];
|
|
13
|
+
expect(new Set([manual, ...ai]).size).toBe(3);
|
|
14
|
+
for (const [index, id] of [manual, ...ai].entries()) {
|
|
15
|
+
expect(validate(id)).toBe(true);
|
|
16
|
+
expect(version(id)).toBe(7);
|
|
17
|
+
const payload = {
|
|
18
|
+
title: `draft ${index}`,
|
|
19
|
+
source: { provider: 'google', connectionId: 'owned' },
|
|
20
|
+
};
|
|
21
|
+
expect(ids.forAttempt(payload, id)).toBe(id);
|
|
22
|
+
expect(ids.forAttempt({ ...payload }, id)).toBe(id);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
it('rejects changed intent for an uncertain draft before dispatch', () => {
|
|
26
|
+
const ids = createCalendarCreationRequests();
|
|
27
|
+
const id = newCalendarCreationRequestId();
|
|
28
|
+
ids.forAttempt({ title: 'Original' }, id);
|
|
29
|
+
expect(() => ids.forAttempt({ title: 'Edited' }, id)).toThrow(
|
|
30
|
+
'draft changed'
|
|
31
|
+
);
|
|
32
|
+
expect(
|
|
33
|
+
ids.forAttempt({ title: 'Edited' }, newCalendarCreationRequestId())
|
|
34
|
+
).not.toBe(id);
|
|
35
|
+
});
|
|
36
|
+
it('allocates a new independent intent per call unless the caller supplies a retry ID', () => {
|
|
37
|
+
const ids = createCalendarCreationRequests();
|
|
38
|
+
const event = { title: 'Same independent contents' };
|
|
39
|
+
expect(ids.forAttempt(event)).not.toBe(ids.forAttempt(event));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts the same serialized intent when object key order changes', () => {
|
|
43
|
+
const ids = createCalendarCreationRequests();
|
|
44
|
+
const id = newCalendarCreationRequestId();
|
|
45
|
+
ids.forAttempt(
|
|
46
|
+
{ title: 'Same', source: { provider: 'google', connectionId: 'owned' } },
|
|
47
|
+
id
|
|
48
|
+
);
|
|
49
|
+
expect(
|
|
50
|
+
ids.forAttempt(
|
|
51
|
+
{ source: { connectionId: 'owned', provider: 'google' }, title: 'Same' },
|
|
52
|
+
id
|
|
53
|
+
)
|
|
54
|
+
).toBe(id);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps optimistic identifiers separate from multi-day segment delimiters', () => {
|
|
58
|
+
const id = createOptimisticEventId();
|
|
59
|
+
expect(id).toMatch(/^optimistic[a-zA-Z0-9]+$/);
|
|
60
|
+
expect(id).not.toContain('-');
|
|
61
|
+
expect(createOptimisticEventId()).not.toBe(id);
|
|
62
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { v7 } from 'uuid';
|
|
2
|
+
|
|
3
|
+
export const newCalendarCreationRequestId = () => v7();
|
|
4
|
+
/** Explicit draft IDs survive retries. Independent calls receive separate IDs;
|
|
5
|
+
* changed intent after an uncertain response must start an explicit new draft. */
|
|
6
|
+
export function createCalendarCreationRequests() {
|
|
7
|
+
const intents = new Map<string, string>();
|
|
8
|
+
return {
|
|
9
|
+
forAttempt(event: object, requestId?: string) {
|
|
10
|
+
const id = requestId ?? v7();
|
|
11
|
+
const intent = JSON.stringify(event, (_key, value) =>
|
|
12
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
? Object.fromEntries(
|
|
14
|
+
Object.keys(value)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((key) => [key, value[key]])
|
|
17
|
+
)
|
|
18
|
+
: value
|
|
19
|
+
);
|
|
20
|
+
const previous = intents.get(id);
|
|
21
|
+
if (previous !== undefined && previous !== intent)
|
|
22
|
+
throw new Error(
|
|
23
|
+
'This calendar draft changed after submission; recover it before starting a new draft'
|
|
24
|
+
);
|
|
25
|
+
if (requestId) intents.set(id, intent);
|
|
26
|
+
return id;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export const createOptimisticEventId = () => {
|
|
31
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
|
32
|
+
return `optimistic${crypto.randomUUID().replaceAll('-', '')}`;
|
|
33
|
+
return `optimistic${Date.now()}${Math.random().toString(36).slice(2)}`;
|
|
34
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { WorkspaceCalendarEventUpdatePayload } from '@tuturuuu/internal-api';
|
|
2
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
3
|
+
|
|
4
|
+
export function calendarEventUpdatePayload(
|
|
5
|
+
input: Partial<CalendarEvent>
|
|
6
|
+
): WorkspaceCalendarEventUpdatePayload {
|
|
7
|
+
const payload: WorkspaceCalendarEventUpdatePayload = {};
|
|
8
|
+
for (const field of [
|
|
9
|
+
'title',
|
|
10
|
+
'description',
|
|
11
|
+
'location',
|
|
12
|
+
'start_at',
|
|
13
|
+
'end_at',
|
|
14
|
+
'locked',
|
|
15
|
+
'source',
|
|
16
|
+
'providerColor',
|
|
17
|
+
] as const) {
|
|
18
|
+
if (input[field] !== undefined)
|
|
19
|
+
Object.assign(payload, { [field]: input[field] });
|
|
20
|
+
}
|
|
21
|
+
if (!input.providerColor && input.color !== undefined)
|
|
22
|
+
payload.color = input.color;
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
3
|
|
|
4
|
-
export const NOTIFICATION_REFRESH_INTERVAL_MS =
|
|
4
|
+
export const NOTIFICATION_REFRESH_INTERVAL_MS = 120_000;
|
|
5
5
|
|
|
6
6
|
interface NotificationSubscriptionEntry {
|
|
7
7
|
queryClients: Map<QueryClient, number>;
|
|
@@ -12,7 +12,8 @@ interface NotificationSubscriptionEntry {
|
|
|
12
12
|
const registry = new Map<string, NotificationSubscriptionEntry>();
|
|
13
13
|
|
|
14
14
|
function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
15
|
-
if (document.visibilityState === 'hidden')
|
|
15
|
+
if (document.visibilityState === 'hidden' || navigator.onLine === false)
|
|
16
|
+
return;
|
|
16
17
|
for (const queryClient of entry.queryClients.keys()) {
|
|
17
18
|
// Only active queries refetch; closed inboxes remain lazy.
|
|
18
19
|
void queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
@@ -22,6 +23,7 @@ function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
|
22
23
|
function stopEntry(entry: NotificationSubscriptionEntry) {
|
|
23
24
|
clearInterval(entry.interval);
|
|
24
25
|
document.removeEventListener('visibilitychange', entry.onVisibilityChange);
|
|
26
|
+
window.removeEventListener('online', entry.onVisibilityChange);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -50,6 +52,7 @@ export function useNotificationSubscription(
|
|
|
50
52
|
};
|
|
51
53
|
registry.set(userId, entry);
|
|
52
54
|
document.addEventListener('visibilitychange', refresh);
|
|
55
|
+
window.addEventListener('online', refresh);
|
|
53
56
|
}
|
|
54
57
|
entry.queryClients.set(
|
|
55
58
|
queryClient,
|