@adatechnology/conversations-ui 0.7.0 → 0.9.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/README.md +96 -1
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -3
- package/dist/participant/index.d.ts +48 -3
- package/dist/participant/index.js +782 -347
- package/dist/preview/index.d.ts +3 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +173 -52
- package/package.json +2 -1
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +1 -1
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +26 -54
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -10
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadHeader.tsx +10 -0
- package/src/participant/ParticipantThreadScreen.tsx +6 -46
- package/src/participant/ParticipantTimeline.tsx +6 -3
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/failedMenuKeyboard.ts +29 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantLabels.ts +6 -0
- package/src/participant/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -0
- package/src/participant/participantThemeContrast.test.ts +106 -0
- package/src/participant/participantTooltips.test.tsx +74 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +3 -1
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/styles.css +43 -17
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useReducer, useRef, type FocusEvent, type KeyboardEvent, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { handleFailedMenuKeyDown } from './failedMenuKeyboard'
|
|
4
|
+
import {
|
|
5
|
+
CLOSED_FAILED_MENU,
|
|
6
|
+
failedMenuReducer,
|
|
7
|
+
resolveFailedMenuTriggerKey,
|
|
8
|
+
type FailedMenuPlacement,
|
|
9
|
+
type FailedMenuState,
|
|
10
|
+
} from './participantFailedMenuState'
|
|
11
|
+
import { useFailedMenuInitialFocus, useFailedMenuOutsideClose, useFailedMenuPlacement } from './useFailedMenuEffects'
|
|
12
|
+
|
|
13
|
+
export type ParticipantFailedMenuController = {
|
|
14
|
+
readonly state: FailedMenuState
|
|
15
|
+
readonly placement: FailedMenuPlacement
|
|
16
|
+
readonly containerRef: RefObject<HTMLSpanElement | null>
|
|
17
|
+
readonly triggerRef: RefObject<HTMLButtonElement | null>
|
|
18
|
+
readonly menuRef: RefObject<HTMLDivElement | null>
|
|
19
|
+
readonly handleTriggerClick: () => void
|
|
20
|
+
readonly handleTriggerKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void
|
|
21
|
+
readonly handleMenuKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
|
|
22
|
+
readonly handleBlur: (event: FocusEvent<HTMLSpanElement>) => void
|
|
23
|
+
readonly close: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useParticipantFailedMenu(): ParticipantFailedMenuController {
|
|
27
|
+
const [state, dispatch] = useReducer(failedMenuReducer, CLOSED_FAILED_MENU)
|
|
28
|
+
const containerRef = useRef<HTMLSpanElement | null>(null)
|
|
29
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
30
|
+
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
31
|
+
|
|
32
|
+
useFailedMenuInitialFocus(state, menuRef)
|
|
33
|
+
useFailedMenuOutsideClose(state.isOpen, containerRef, dispatch)
|
|
34
|
+
const placement = useFailedMenuPlacement(state.isOpen, { triggerRef, menuRef })
|
|
35
|
+
|
|
36
|
+
function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>): void {
|
|
37
|
+
const initialFocus = resolveFailedMenuTriggerKey(event.key)
|
|
38
|
+
if (!initialFocus) return
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
dispatch({ type: 'open', initialFocus })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleBlur(event: FocusEvent<HTMLSpanElement>): void {
|
|
44
|
+
const next = event.relatedTarget
|
|
45
|
+
if (next instanceof Node && !containerRef.current?.contains(next)) dispatch({ type: 'close' })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
state,
|
|
50
|
+
placement,
|
|
51
|
+
containerRef,
|
|
52
|
+
triggerRef,
|
|
53
|
+
menuRef,
|
|
54
|
+
handleTriggerClick: () => dispatch({ type: 'toggle' }),
|
|
55
|
+
handleTriggerKeyDown,
|
|
56
|
+
handleMenuKeyDown: (event) => handleFailedMenuKeyDown({ event, menuRef, triggerRef, dispatch }),
|
|
57
|
+
handleBlur,
|
|
58
|
+
close: () => dispatch({ type: 'close' }),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -17,9 +17,11 @@ import {
|
|
|
17
17
|
type ParticipantSendEntry,
|
|
18
18
|
} from './participantSendController'
|
|
19
19
|
import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
|
|
20
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
20
21
|
import { useParticipantMarkRead } from './useParticipantMarkRead'
|
|
21
22
|
import { useParticipantRevalidation } from './useParticipantRevalidation'
|
|
22
23
|
import { useParticipantSend, type UseParticipantSendResult } from './useParticipantSend'
|
|
24
|
+
import { useParticipantUnread } from './useParticipantUnread'
|
|
23
25
|
|
|
24
26
|
const THREAD_PAGE_SIZE = 30
|
|
25
27
|
|
|
@@ -35,6 +37,8 @@ export type UseParticipantThreadParams = {
|
|
|
35
37
|
readonly sendEntries: readonly ParticipantSendEntry[]
|
|
36
38
|
readonly dispatchSend: (action: ParticipantSendAction) => void
|
|
37
39
|
readonly hostPending?: readonly ParticipantPendingMessage[]
|
|
40
|
+
/** Present on a single-conversation screen: unread comes from the messages, not from `unreadCount`. */
|
|
41
|
+
readonly perspective?: ParticipantPerspective
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export type UseParticipantThreadResult = ParticipantThreadState &
|
|
@@ -55,7 +59,7 @@ function isForSubject(event: ParticipantConversationEvent | undefined, subject:
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export function useParticipantThread(params: UseParticipantThreadParams): UseParticipantThreadResult {
|
|
58
|
-
const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [] } = params
|
|
62
|
+
const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [], perspective } = params
|
|
59
63
|
const { subjectType, subjectId } = subject
|
|
60
64
|
const stableSubject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
|
|
61
65
|
const [state, dispatch] = useReducer(participantThreadReducer, INITIAL_PARTICIPANT_THREAD_STATE)
|
|
@@ -109,7 +113,14 @@ export function useParticipantThread(params: UseParticipantThreadParams): UsePar
|
|
|
109
113
|
dispatchSend({ type: 'reflected', knownClientMessageIds: collectKnownClientMessageIds({ messages: state.messages, hostPending }) })
|
|
110
114
|
}, [sendEntries, state.messages, hostPending, dispatchSend])
|
|
111
115
|
|
|
112
|
-
|
|
116
|
+
const unread = useParticipantUnread({ messages: state.messages, perspective, reportedUnreadCount: unreadCount, onMarkedRead })
|
|
117
|
+
useParticipantMarkRead({
|
|
118
|
+
apiRef,
|
|
119
|
+
subject: stableSubject,
|
|
120
|
+
unreadCount: unread.unreadCount,
|
|
121
|
+
messageCount: state.messages.length,
|
|
122
|
+
onMarkedRead: unread.handleMarkedRead,
|
|
123
|
+
})
|
|
113
124
|
|
|
114
125
|
const localPending = useMemo(() => toLocalPending(sendEntries), [sendEntries])
|
|
115
126
|
return { ...state, localPending, confirmsRead, refresh, loadOlder, ...sending }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useMemo, type Dispatch, type MutableRefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
6
|
+
import type { ParticipantThreadProps } from './ParticipantThread'
|
|
7
|
+
import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
|
|
8
|
+
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
9
|
+
import type { ParticipantDrafts } from './participantDrafts'
|
|
10
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
11
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
12
|
+
import { findFailedEntry, hasSendingEntry, type ParticipantSendAction } from './participantSendController'
|
|
13
|
+
import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
|
|
14
|
+
import { useParticipantConversationView } from './useParticipantConversationView'
|
|
15
|
+
import { useParticipantDraft } from './useParticipantDraft'
|
|
16
|
+
import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
|
|
17
|
+
import { useParticipantThreadScroll } from './useParticipantThreadScroll'
|
|
18
|
+
|
|
19
|
+
export type ParticipantThreadControllerParams = {
|
|
20
|
+
readonly api: ParticipantConversationsApi
|
|
21
|
+
readonly subject: ParticipantSubjectRef
|
|
22
|
+
readonly conversation: ParticipantConversationSummary
|
|
23
|
+
readonly labels: ParticipantConversationsLabels
|
|
24
|
+
/** Holds the immutable drafts of every subject; replaced, never mutated. */
|
|
25
|
+
readonly draftsRef: MutableRefObject<ParticipantDrafts>
|
|
26
|
+
readonly sendStates: ParticipantSendStates
|
|
27
|
+
readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
|
|
28
|
+
readonly onMarkedRead: (subject: ParticipantSubjectRef) => void
|
|
29
|
+
readonly channel?: ConversationChannel
|
|
30
|
+
readonly locale?: string
|
|
31
|
+
readonly pendingMessages?: readonly ParticipantPendingMessage[]
|
|
32
|
+
readonly onRetryPending?: (clientMessageId: string) => void
|
|
33
|
+
readonly perspective?: ParticipantPerspective
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Everything a conversation screen needs from the hooks; the caller adds only its own presentation props. */
|
|
37
|
+
export type ParticipantThreadCoreProps = Pick<
|
|
38
|
+
ParticipantThreadProps,
|
|
39
|
+
| 'conversation'
|
|
40
|
+
| 'items'
|
|
41
|
+
| 'hasMore'
|
|
42
|
+
| 'labels'
|
|
43
|
+
| 'resolveAttachmentUrl'
|
|
44
|
+
| 'draft'
|
|
45
|
+
| 'onSend'
|
|
46
|
+
| 'status'
|
|
47
|
+
| 'refresh'
|
|
48
|
+
| 'scroll'
|
|
49
|
+
| 'newMessagesCount'
|
|
50
|
+
| 'isSending'
|
|
51
|
+
| 'channel'
|
|
52
|
+
| 'locale'
|
|
53
|
+
| 'onLoadOlder'
|
|
54
|
+
| 'pendingActions'
|
|
55
|
+
| 'perspective'
|
|
56
|
+
>
|
|
57
|
+
|
|
58
|
+
type BuildPendingActionsParams = {
|
|
59
|
+
readonly thread: UseParticipantThreadResult
|
|
60
|
+
readonly dispatchSend: (action: ParticipantSendAction) => void
|
|
61
|
+
readonly handleEdit: (clientMessageId: string) => void
|
|
62
|
+
readonly onRetryPending: ParticipantThreadControllerParams['onRetryPending']
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending }: BuildPendingActionsParams): ParticipantThreadCoreProps['pendingActions'] {
|
|
66
|
+
return {
|
|
67
|
+
retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
|
|
68
|
+
discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
|
|
69
|
+
editLocal: handleEdit,
|
|
70
|
+
...(onRetryPending ? { retryHost: onRetryPending } : {}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useParticipantThreadController(params: ParticipantThreadControllerParams): ParticipantThreadCoreProps {
|
|
75
|
+
const { api, subject, conversation, draftsRef } = params
|
|
76
|
+
const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject)
|
|
77
|
+
const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params)
|
|
78
|
+
const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
|
|
79
|
+
const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective)
|
|
80
|
+
|
|
81
|
+
function handleSend(): void {
|
|
82
|
+
const content = takeForSend()
|
|
83
|
+
if (content) void thread.send(content)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleEdit(clientMessageId: string): void {
|
|
87
|
+
const entry = findFailedEntry(sendEntries, clientMessageId)
|
|
88
|
+
if (!entry) return
|
|
89
|
+
restoreFromEntry(entry)
|
|
90
|
+
dispatchSend({ type: 'discarded', clientMessageId })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
conversation,
|
|
95
|
+
items,
|
|
96
|
+
hasMore: thread.hasMore,
|
|
97
|
+
labels: params.labels,
|
|
98
|
+
resolveAttachmentUrl,
|
|
99
|
+
draft: { value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles },
|
|
100
|
+
onSend: handleSend,
|
|
101
|
+
status: thread.status,
|
|
102
|
+
refresh: () => void thread.refresh(),
|
|
103
|
+
scroll,
|
|
104
|
+
newMessagesCount,
|
|
105
|
+
isSending: hasSendingEntry(sendEntries),
|
|
106
|
+
channel: params.channel,
|
|
107
|
+
locale: params.locale,
|
|
108
|
+
onLoadOlder: () => void thread.loadOlder(),
|
|
109
|
+
pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
|
|
110
|
+
perspective: params.perspective,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
|
|
4
4
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
5
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
5
6
|
import { useScrollAwayState } from './useScrollAwayState'
|
|
6
7
|
import { useStickToBottom, type ParticipantThreadScroll } from './useStickToBottom'
|
|
7
8
|
|
|
@@ -17,6 +18,7 @@ function keyOf(item: ParticipantTimelineItem | undefined): string | undefined {
|
|
|
17
18
|
|
|
18
19
|
export function useParticipantThreadScroll(
|
|
19
20
|
items: readonly ParticipantTimelineItem[],
|
|
21
|
+
perspective?: ParticipantPerspective,
|
|
20
22
|
): UseParticipantThreadScrollResult {
|
|
21
23
|
const { scroll: base, isNearBottom } = useStickToBottom({
|
|
22
24
|
firstKey: keyOf(items[0]),
|
|
@@ -30,5 +32,5 @@ export function useParticipantThreadScroll(
|
|
|
30
32
|
if (isNearBottom) seenIds.current = collectServerMessageIds(items)
|
|
31
33
|
}, [isNearBottom, items])
|
|
32
34
|
|
|
33
|
-
return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items) }
|
|
35
|
+
return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items, perspective) }
|
|
34
36
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
|
|
5
|
+
|
|
6
|
+
import { buildMessage } from './participantFixtures.test-helper'
|
|
7
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
8
|
+
import { useParticipantUnread } from './useParticipantUnread'
|
|
9
|
+
|
|
10
|
+
const MESSAGES: readonly ParticipantMessage[] = [
|
|
11
|
+
buildMessage({ id: 'a', direction: 'inbound' }),
|
|
12
|
+
buildMessage({ id: 'b', direction: 'inbound' }),
|
|
13
|
+
buildMessage({ id: 'c', direction: 'outbound' }),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
type ProbeProps = { readonly perspective?: ParticipantPerspective; readonly reported: number }
|
|
17
|
+
|
|
18
|
+
function Probe({ perspective, reported }: ProbeProps) {
|
|
19
|
+
const { unreadCount } = useParticipantUnread({ messages: MESSAGES, perspective, reportedUnreadCount: reported })
|
|
20
|
+
return <output>{unreadCount}</output>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unreadOf(props: ProbeProps): string {
|
|
24
|
+
return renderToStaticMarkup(<Probe {...props} />)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('useParticipantUnread', () => {
|
|
28
|
+
it('keeps the count the inbox reported when no perspective is given', () => {
|
|
29
|
+
expect(unreadOf({ reported: 3 })).toBe('<output>3</output>')
|
|
30
|
+
expect(unreadOf({ reported: 0 })).toBe('<output>0</output>')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('derives from the messages for the operator: only the inbound ones, whatever the inbox said', () => {
|
|
34
|
+
expect(unreadOf({ perspective: 'operator', reported: 9 })).toBe('<output>2</output>')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('derives from the messages for the participant: only the outbound ones', () => {
|
|
38
|
+
expect(unreadOf({ perspective: 'participant', reported: 9 })).toBe('<output>1</output>')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import { countOtherSideMessages, resolveViewerUnread, type ParticipantPerspective } from './participantPerspective'
|
|
6
|
+
|
|
7
|
+
export type UseParticipantUnreadParams = {
|
|
8
|
+
readonly messages: readonly ParticipantMessage[]
|
|
9
|
+
/** Absent keeps the count the inbox reported, which is how the list-driven screen works. */
|
|
10
|
+
readonly perspective?: ParticipantPerspective
|
|
11
|
+
readonly reportedUnreadCount: number
|
|
12
|
+
readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type UseParticipantUnreadResult = {
|
|
16
|
+
readonly unreadCount: number
|
|
17
|
+
readonly handleMarkedRead: (subject: ParticipantSubjectRef) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A single conversation has no inbox, so what the viewer has not read is derived from the messages themselves. */
|
|
21
|
+
export function useParticipantUnread(params: UseParticipantUnreadParams): UseParticipantUnreadResult {
|
|
22
|
+
const { messages, perspective, reportedUnreadCount, onMarkedRead } = params
|
|
23
|
+
const [acknowledgedCount, setAcknowledgedCount] = useState(0)
|
|
24
|
+
const messagesRef = useRef(messages)
|
|
25
|
+
messagesRef.current = messages
|
|
26
|
+
|
|
27
|
+
const handleMarkedRead = useCallback(
|
|
28
|
+
(subject: ParticipantSubjectRef): void => {
|
|
29
|
+
if (perspective) setAcknowledgedCount(countOtherSideMessages(messagesRef.current, perspective))
|
|
30
|
+
onMarkedRead?.(subject)
|
|
31
|
+
},
|
|
32
|
+
[perspective, onMarkedRead],
|
|
33
|
+
)
|
|
34
|
+
const unreadCount = perspective
|
|
35
|
+
? resolveViewerUnread({ messages, perspective, acknowledgedCount })
|
|
36
|
+
: reportedUnreadCount
|
|
37
|
+
return { unreadCount, handleMarkedRead }
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the package tooltip layer (which listens on the document) from lighting up for touch: a tap
|
|
5
|
+
* focuses the button on Android and the balloon would stay on. The events are stopped at the thread
|
|
6
|
+
* root, so the layer keeps its behavior for every other flow.
|
|
7
|
+
*/
|
|
8
|
+
export function useSuppressTouchTooltips(rootRef: RefObject<HTMLElement | null>): void {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const root = rootRef.current
|
|
11
|
+
if (!root) return undefined
|
|
12
|
+
let isTouching = false
|
|
13
|
+
|
|
14
|
+
function handlePointerDown(event: PointerEvent): void {
|
|
15
|
+
isTouching = event.pointerType === 'touch'
|
|
16
|
+
}
|
|
17
|
+
function handlePointerOver(event: PointerEvent): void {
|
|
18
|
+
if (event.pointerType === 'touch') event.stopPropagation()
|
|
19
|
+
}
|
|
20
|
+
function handleFocusIn(event: Event): void {
|
|
21
|
+
if (isTouching) event.stopPropagation()
|
|
22
|
+
}
|
|
23
|
+
function handleKeyDown(): void {
|
|
24
|
+
isTouching = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
root.addEventListener('pointerdown', handlePointerDown, true)
|
|
28
|
+
root.addEventListener('pointerover', handlePointerOver)
|
|
29
|
+
root.addEventListener('focusin', handleFocusIn)
|
|
30
|
+
root.addEventListener('keydown', handleKeyDown, true)
|
|
31
|
+
return () => {
|
|
32
|
+
root.removeEventListener('pointerdown', handlePointerDown, true)
|
|
33
|
+
root.removeEventListener('pointerover', handlePointerOver)
|
|
34
|
+
root.removeEventListener('focusin', handleFocusIn)
|
|
35
|
+
root.removeEventListener('keydown', handleKeyDown, true)
|
|
36
|
+
}
|
|
37
|
+
}, [rootRef])
|
|
38
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -1139,7 +1139,7 @@
|
|
|
1139
1139
|
|
|
1140
1140
|
/* Participant view (.cv-p-*). Public theming API: override --cv-p-surface, --cv-p-surface-raised,
|
|
1141
1141
|
--cv-p-text, --cv-p-text-muted, --cv-p-border, --cv-p-accent, --cv-p-accent-contrast,
|
|
1142
|
-
--cv-p-highlight, --cv-p-danger, --cv-p-radius, --cv-p-wallpaper, --cv-p-scrollbar-thumb, --cv-p-scrollbar-thumb-hover, --cv-p-channel-app, --cv-p-channel-whatsapp on .cv-p or any
|
|
1142
|
+
--cv-p-highlight, --cv-p-danger, --cv-p-radius, --cv-p-wallpaper, --cv-p-scrollbar-thumb, --cv-p-scrollbar-thumb-hover, --cv-p-tick, --cv-p-tick-read, --cv-p-channel-app, --cv-p-channel-whatsapp on .cv-p or any
|
|
1143
1143
|
ancestor. Channel colors must keep 3:1 against surface, surface-raised and highlight. */
|
|
1144
1144
|
.cv-p {
|
|
1145
1145
|
--cv-p-i-surface: var(--cv-p-surface, #ffffff);
|
|
@@ -1149,12 +1149,14 @@
|
|
|
1149
1149
|
--cv-p-i-border: var(--cv-p-border, #d5d9df);
|
|
1150
1150
|
--cv-p-i-accent: var(--cv-p-accent, #a85a1c);
|
|
1151
1151
|
--cv-p-i-accent-contrast: var(--cv-p-accent-contrast, #ffffff);
|
|
1152
|
-
--cv-p-i-highlight: var(--cv-p-highlight, #
|
|
1152
|
+
--cv-p-i-highlight: var(--cv-p-highlight, #fcf3ea);
|
|
1153
1153
|
--cv-p-i-danger: var(--cv-p-danger, #c62828);
|
|
1154
1154
|
--cv-p-i-radius: var(--cv-p-radius, 0);
|
|
1155
1155
|
--cv-p-i-touch: 2.75rem;
|
|
1156
1156
|
--cv-p-i-scrollbar-thumb: var(--cv-p-scrollbar-thumb, var(--cv-p-i-border));
|
|
1157
1157
|
--cv-p-i-scrollbar-thumb-hover: var(--cv-p-scrollbar-thumb-hover, var(--cv-p-i-accent));
|
|
1158
|
+
--cv-p-i-tick: var(--cv-p-tick, var(--cv-p-i-text-muted));
|
|
1159
|
+
--cv-p-i-tick-read: var(--cv-p-tick-read, #0369a1);
|
|
1158
1160
|
--cv-p-i-wallpaper-ink: var(--cv-p-i-border);
|
|
1159
1161
|
--cv-p-i-wallpaper: radial-gradient(circle at 2px 2px, var(--cv-p-i-wallpaper-ink) 1px, transparent 1.5px), radial-gradient(circle at 14px 14px, var(--cv-p-i-wallpaper-ink) 1px, transparent 1.5px);
|
|
1160
1162
|
display: flex;
|
|
@@ -1177,6 +1179,7 @@
|
|
|
1177
1179
|
--cv-p-i-accent-contrast: var(--cv-p-accent-contrast, #10222c);
|
|
1178
1180
|
--cv-p-i-highlight: var(--cv-p-highlight, rgb(213 138 71 / 10%));
|
|
1179
1181
|
--cv-p-i-danger: var(--cv-p-danger, #ff6b63);
|
|
1182
|
+
--cv-p-i-tick-read: var(--cv-p-tick-read, #5cc8ff);
|
|
1180
1183
|
}
|
|
1181
1184
|
.cv-p *, .cv-p *::before, .cv-p *::after { box-sizing: border-box; }
|
|
1182
1185
|
.cv-p :focus-visible { outline: 2px solid var(--cv-p-i-accent); outline-offset: 2px; }
|
|
@@ -1185,7 +1188,7 @@
|
|
|
1185
1188
|
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
1186
1189
|
}
|
|
1187
1190
|
|
|
1188
|
-
.cv-p-button, .cv-p-chip, .cv-p-row, .cv-p-thread__older, .cv-p-
|
|
1191
|
+
.cv-p-button, .cv-p-chip, .cv-p-row, .cv-p-thread__older, .cv-p-failed-menu__trigger, .cv-p-failed-menu__item {
|
|
1189
1192
|
font: inherit; color: inherit; cursor: pointer;
|
|
1190
1193
|
}
|
|
1191
1194
|
.cv-p-button {
|
|
@@ -1211,6 +1214,7 @@
|
|
|
1211
1214
|
.cv-p-filters { --cv-p-i-fade: 1rem; display: flex; gap: 0.5rem; padding: 0.625rem 1rem; overflow-x: auto; border-bottom: 1px solid var(--cv-p-i-border); }
|
|
1212
1215
|
.cv-p-error { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 1.5rem 1rem; text-align: center; color: var(--cv-p-i-danger, #b91c1c); }
|
|
1213
1216
|
.cv-p-error__message { margin: 0; }
|
|
1217
|
+
.cv-p-thread__scroll > .cv-p-error { background: var(--cv-p-i-surface); }
|
|
1214
1218
|
.cv-p-loading { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; }
|
|
1215
1219
|
.cv-p-skeleton { display: block; height: 3rem; border-radius: var(--cv-p-i-radius); background: var(--cv-p-i-surface-raised); }
|
|
1216
1220
|
.cv-p-inbox__more { display: block; margin: 0.75rem auto; padding: 0 1rem; }
|
|
@@ -1258,6 +1262,10 @@
|
|
|
1258
1262
|
.cv-p-thread__eyebrow { margin: 0; font: 0.64rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
|
|
1259
1263
|
.cv-p-thread__title { margin: 0; font-size: 1rem; font-weight: 600; line-height: 1.3; min-width: 0; overflow-wrap: anywhere; }
|
|
1260
1264
|
.cv-p-thread__title-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; line-clamp: 1; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
1265
|
+
.cv-p-thread__counterpart { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.78rem; line-height: 1.3; color: var(--cv-p-i-text-muted); }
|
|
1266
|
+
.cv-p-thread__actions { flex: 0 0 auto; display: flex; align-items: center; gap: 0.5rem; margin-inline-start: auto; max-width: 100%; }
|
|
1267
|
+
.cv-p-thread__header:has(.cv-p-thread__actions) { flex-wrap: wrap; }
|
|
1268
|
+
.cv-p-thread__header:has(.cv-p-thread__actions) .cv-p-thread__heading { min-width: 6rem; }
|
|
1261
1269
|
.cv-p-thread__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.125rem 0.5rem; min-width: 0; padding-top: 0.5rem; }
|
|
1262
1270
|
.cv-p-thread__title-button { display: flex; align-items: center; max-width: 100%; min-height: var(--cv-p-i-touch); padding: 0; background: none; border: 0; color: inherit; font: inherit; font-weight: 600; text-align: start; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.2em; cursor: pointer; }
|
|
1263
1271
|
.cv-p-thread__subject-card { margin: 0.75rem 1rem 0; padding: 0.625rem 0.75rem; border: 1px solid var(--cv-p-i-border); background: var(--cv-p-i-surface-raised); border-radius: var(--cv-p-i-radius); font-size: 0.82rem; }
|
|
@@ -1265,7 +1273,7 @@
|
|
|
1265
1273
|
.cv-p-thread__scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-anchor: none; touch-action: pan-y pinch-zoom; padding: 0.5rem 0.75rem 1rem; background-image: var(--cv-p-wallpaper, var(--cv-p-i-wallpaper)); background-size: var(--cv-p-wallpaper-size, 24px 24px); }
|
|
1266
1274
|
.cv-p-thread__older { display: block; margin: 0 auto 0.5rem; min-height: var(--cv-p-i-touch); padding: 0 1rem; background: transparent; border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); }
|
|
1267
1275
|
.cv-p-thread__messages { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
1268
|
-
.cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); }
|
|
1276
|
+
.cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); background: var(--cv-p-i-surface-raised); }
|
|
1269
1277
|
.cv-p-thread__live:empty { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
1270
1278
|
.cv-p-thread__closed { margin: 0; padding: 1rem; text-align: center; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
|
|
1271
1279
|
.cv-p-day { align-self: center; margin: 0.5rem 0; padding: 0.25rem 0.75rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); font: 0.65rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.06em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
|
|
@@ -1285,14 +1293,6 @@
|
|
|
1285
1293
|
.cv-p-bubble-row > .cv-p-bubble:not(.cv-p-bubble--no-tail) { margin-left: 0.25rem; }
|
|
1286
1294
|
.cv-p-bubble--no-tail { border-radius: var(--cv-p-i-radius); }
|
|
1287
1295
|
.cv-p-bubble--no-tail::before, .cv-p-bubble--no-tail::after { content: none; }
|
|
1288
|
-
@media (forced-colors: active) {
|
|
1289
|
-
.cv-p-bubble::before, .cv-p-bubble::after { content: none; }
|
|
1290
|
-
.cv-p-bubble, .cv-p-bubble--mine { border-radius: var(--cv-p-i-radius); }
|
|
1291
|
-
.cv-p-thread__scroll { background-image: none; }
|
|
1292
|
-
.cv-p-thread__scroll, .cv-p-inbox, .cv-p-composer__input { scrollbar-width: auto; scrollbar-color: auto; }
|
|
1293
|
-
.cv-p-filters, .cv-p-quick { mask-image: none; -webkit-mask-image: none; }
|
|
1294
|
-
.cv-p-thread__latest-badge { border: 1px solid ButtonText; }
|
|
1295
|
-
}
|
|
1296
1296
|
@media print {
|
|
1297
1297
|
.cv-p-bubble::before, .cv-p-bubble::after { content: none; }
|
|
1298
1298
|
.cv-p-bubble, .cv-p-bubble--mine { border-radius: var(--cv-p-i-radius); }
|
|
@@ -1300,12 +1300,27 @@
|
|
|
1300
1300
|
}
|
|
1301
1301
|
.cv-p-bubble__author { display: block; flex: 1 0 100%; margin-bottom: 0.15rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; text-transform: uppercase; color: var(--cv-p-i-accent); }
|
|
1302
1302
|
.cv-p-bubble > .cv-p-text { flex: 0 1 auto; min-width: 0; margin-inline-end: auto; }
|
|
1303
|
-
.cv-p-bubble > .cv-p-attachment
|
|
1303
|
+
.cv-p-bubble > .cv-p-attachment { flex: 1 0 100%; }
|
|
1304
1304
|
.cv-p-bubble__meta { display: flex; flex: 0 0 auto; justify-content: flex-end; align-items: center; gap: 0.35rem; margin-inline-start: auto; margin-top: 0.25rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; color: var(--cv-p-i-text-muted); }
|
|
1305
1305
|
.cv-p-bubble__status { display: inline-flex; align-items: center; gap: 0.25rem; }
|
|
1306
|
-
.cv-p-
|
|
1307
|
-
.cv-p-
|
|
1308
|
-
.cv-p-
|
|
1306
|
+
.cv-p-bubble .cv-status-ticks--queued, .cv-p-bubble .cv-status-ticks--sent, .cv-p-bubble .cv-status-ticks--delivered { color: var(--cv-p-i-tick); }
|
|
1307
|
+
.cv-p-bubble .cv-status-ticks--read { color: var(--cv-p-i-tick-read); }
|
|
1308
|
+
.cv-p-bubble .cv-status-ticks--failed { color: var(--cv-p-i-danger); }
|
|
1309
|
+
@supports (color: color-mix(in srgb, red, blue)) {
|
|
1310
|
+
.cv-p-bubble--failed { border-color: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-border)); --cv-p-tail-edge: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-border)); }
|
|
1311
|
+
.cv-p-bubble--mine.cv-p-bubble--failed { border-color: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-accent)); --cv-p-tail-edge: color-mix(in srgb, var(--cv-p-i-danger) 55%, var(--cv-p-i-accent)); }
|
|
1312
|
+
}
|
|
1313
|
+
.cv-p-failed-retry { position: absolute; top: 50%; right: calc(100% + 0.125rem); transform: translateY(-50%); display: grid; place-items: center; width: var(--cv-p-i-touch); height: var(--cv-p-i-touch); padding: 0; background: transparent; border: 0; color: var(--cv-p-i-danger); font: inherit; cursor: pointer; }
|
|
1314
|
+
.cv-p-failed-retry__icon { display: grid; place-items: center; width: 2rem; height: 2rem; border: 1px solid var(--cv-p-i-danger); border-radius: 50%; }
|
|
1315
|
+
.cv-p-failed-retry:hover .cv-p-failed-retry__icon, .cv-p-failed-retry:focus-visible .cv-p-failed-retry__icon { background: var(--cv-p-i-surface-raised); }
|
|
1316
|
+
.cv-p-failed-menu { display: inline-flex; }
|
|
1317
|
+
.cv-p-failed-menu__trigger { position: relative; display: inline-grid; place-items: center; min-width: 1.25rem; height: 0.75rem; padding: 0; background: transparent; border: 0; color: var(--cv-p-i-text-muted); font: inherit; line-height: 0.75rem; cursor: pointer; }
|
|
1318
|
+
.cv-p-failed-menu__trigger::after { content: ''; position: absolute; inset: -0.5rem -0.4rem; }
|
|
1319
|
+
.cv-p-failed-menu__list { position: absolute; right: 0; bottom: calc(100% + 0.25rem); z-index: 2; display: flex; flex-direction: column; min-width: 8rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); }
|
|
1320
|
+
.cv-p-failed-menu__item { min-height: var(--cv-p-i-touch); padding: 0 0.75rem; background: transparent; border: 0; color: var(--cv-p-i-text); font: 0.7rem 'SFMono-Regular', Consolas, monospace; text-align: start; text-transform: uppercase; letter-spacing: 0.04em; cursor: pointer; }
|
|
1321
|
+
.cv-p-failed-menu__list--below { bottom: auto; top: calc(100% + 0.25rem); }
|
|
1322
|
+
.cv-p-failed-menu__item--danger { color: var(--cv-p-i-danger); }
|
|
1323
|
+
.cv-p-failed-menu__item:hover, .cv-p-failed-menu__item:focus-visible { background: var(--cv-p-i-highlight); }
|
|
1309
1324
|
.cv-p-attachment { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.35rem; color: inherit; }
|
|
1310
1325
|
.cv-p-attachment--document { flex-direction: row; justify-content: space-between; min-height: var(--cv-p-i-touch); align-items: center; text-decoration: underline; }
|
|
1311
1326
|
.cv-p-attachment__image { display: block; max-width: 100%; max-height: 16rem; object-fit: contain; }
|
|
@@ -1405,7 +1420,18 @@
|
|
|
1405
1420
|
.cv-p-text__link-copy::after { inset-inline: 0 auto; min-width: var(--cv-p-i-touch); }
|
|
1406
1421
|
.cv-p-copyable { padding: 0 0.15rem; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.92em; background: color-mix(in srgb, var(--cv-p-i-accent) 14%, transparent); border-bottom: 1px dotted var(--cv-p-i-accent); border-radius: var(--cv-p-i-radius); }
|
|
1407
1422
|
.cv-p-copyable--email { font-family: inherit; }
|
|
1408
|
-
.cv-p-copyable__done { margin-inline-start: 0.35rem; font-size: 0.85em; color: var(--cv-p-i-
|
|
1423
|
+
.cv-p-copyable__done { margin-inline-start: 0.35rem; font-size: 0.85em; color: var(--cv-p-i-text); }
|
|
1424
|
+
@media (forced-colors: active) {
|
|
1425
|
+
.cv-p-bubble::before, .cv-p-bubble::after { content: none; }
|
|
1426
|
+
.cv-p-bubble, .cv-p-bubble--mine { border-radius: var(--cv-p-i-radius); }
|
|
1427
|
+
.cv-p-thread__scroll { background-image: none; }
|
|
1428
|
+
.cv-p-thread__scroll, .cv-p-inbox, .cv-p-composer__input { scrollbar-width: auto; scrollbar-color: auto; }
|
|
1429
|
+
.cv-p-filters, .cv-p-quick { mask-image: none; -webkit-mask-image: none; }
|
|
1430
|
+
.cv-p-thread__latest-badge { border: 1px solid ButtonText; }
|
|
1431
|
+
.cv-p-bubble .cv-status-ticks, .cv-p-copyable__done { color: CanvasText; }
|
|
1432
|
+
.cv-p-failed-menu__list { border-color: CanvasText; }
|
|
1433
|
+
.cv-p-failed-menu__item:focus-visible, .cv-p-failed-menu__item:hover { outline: 2px solid Highlight; }
|
|
1434
|
+
}
|
|
1409
1435
|
@media (forced-colors: active) {
|
|
1410
1436
|
.cv-p-text__code, .cv-p-text__pre, .cv-p-copyable { border: 1px solid; background: none; }
|
|
1411
1437
|
.cv-p-text__link, .cv-p-text__link-copy { color: LinkText; }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { createTooltipLayerRegistry } from './tooltipLayerRegistry'
|
|
4
|
+
|
|
5
|
+
function track() {
|
|
6
|
+
const events: boolean[] = []
|
|
7
|
+
return { events, notify: (isOwner: boolean) => void events.push(isOwner) }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('createTooltipLayerRegistry', () => {
|
|
11
|
+
it('makes the first mounted layer the owner and keeps the others quiet', () => {
|
|
12
|
+
const registry = createTooltipLayerRegistry()
|
|
13
|
+
const first = track()
|
|
14
|
+
const second = track()
|
|
15
|
+
registry.register({}, first.notify)
|
|
16
|
+
registry.register({}, second.notify)
|
|
17
|
+
|
|
18
|
+
expect(first.events).toEqual([true])
|
|
19
|
+
expect(second.events).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('hands the ownership to the next mounted layer when the owner unmounts', () => {
|
|
23
|
+
const registry = createTooltipLayerRegistry()
|
|
24
|
+
const first = track()
|
|
25
|
+
const second = track()
|
|
26
|
+
const third = track()
|
|
27
|
+
const unmountFirst = registry.register({}, first.notify)
|
|
28
|
+
registry.register({}, second.notify)
|
|
29
|
+
registry.register({}, third.notify)
|
|
30
|
+
|
|
31
|
+
unmountFirst()
|
|
32
|
+
|
|
33
|
+
expect(second.events).toEqual([true])
|
|
34
|
+
expect(third.events).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not disturb the owner when a waiting layer unmounts', () => {
|
|
38
|
+
const registry = createTooltipLayerRegistry()
|
|
39
|
+
const first = track()
|
|
40
|
+
const second = track()
|
|
41
|
+
registry.register({}, first.notify)
|
|
42
|
+
registry.register({}, second.notify)()
|
|
43
|
+
|
|
44
|
+
expect(first.events).toEqual([true])
|
|
45
|
+
expect(second.events).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('lets a layer mounted after everyone left become the owner again', () => {
|
|
49
|
+
const registry = createTooltipLayerRegistry()
|
|
50
|
+
registry.register({}, () => undefined)()
|
|
51
|
+
const late = track()
|
|
52
|
+
registry.register({}, late.notify)
|
|
53
|
+
|
|
54
|
+
expect(late.events).toEqual([true])
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type TooltipLayerRegistry = {
|
|
2
|
+
/** Mounts a layer; the callback hears when it becomes (true) or stops being (false) the one that draws. */
|
|
3
|
+
readonly register: (layer: object, onOwnershipChange: (isOwner: boolean) => void) => () => void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Layers in mount order: one draws at a time, and when it unmounts the next mounted one takes over. */
|
|
7
|
+
export function createTooltipLayerRegistry(): TooltipLayerRegistry {
|
|
8
|
+
const layers = new Map<object, (isOwner: boolean) => void>()
|
|
9
|
+
let owner: object | undefined
|
|
10
|
+
|
|
11
|
+
function handOff(): void {
|
|
12
|
+
const next = layers.entries().next()
|
|
13
|
+
if (next.done) {
|
|
14
|
+
owner = undefined
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const [layer, onOwnershipChange] = next.value
|
|
18
|
+
owner = layer
|
|
19
|
+
onOwnershipChange(true)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
register(layer, onOwnershipChange) {
|
|
24
|
+
layers.set(layer, onOwnershipChange)
|
|
25
|
+
if (owner === undefined) handOff()
|
|
26
|
+
return () => {
|
|
27
|
+
layers.delete(layer)
|
|
28
|
+
if (owner !== layer) return
|
|
29
|
+
owner = undefined
|
|
30
|
+
handOff()
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|