b422765aed
* tweak in-post threadgate button * tweak composer threadgate button * reduce date length slightly * pressed styles * make date length depend on breakpoint * add chevron to label btn * add tiny chevron, special-case button icon width * [Threadgate] Add hint (#9350) * get tooltip working on web * add compatibility layer for working in iOS sheets * add timeout to profile tooltip now that it appears instantly * rm debug code * Update ThreadgateBtn.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * remeasure when keyboard changes --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix types * [Threadgate] Refresh dialog (#9342) * wip new ui * update threadgate dialog with new designs * restore nobody option * relayout android sheet when ratio changes * fix ratio changing case in bottom sheet * timebox reached, use setTimeout * update panel styles * missing imports * extract out Panel * tweak layout animation * fix icon color * use same color mechamism for icon as text * restore the header * refreshed toggle styles (#9343) * [Threadgate] Persist settings (#9341) * add persist toggle to threadgate dialog * move state back down * sort out spacing * wire up query * @surfdude29 tweaks * use tiny chevron in WhoCanReply * wait for prefetch before opening * move Panel into the Toggle namespace * default -> pref * use medium date length * rm hover state from web selects, fix border radius * fix key issue in Selects --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
import {
|
|
Children,
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {useWindowDimensions, View} from 'react-native'
|
|
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
|
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
|
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
|
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
|
import {atoms as a, select, useTheme} from '#/alf'
|
|
import {useOnGesture} from '#/components/hooks/useOnGesture'
|
|
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
|
|
import {
|
|
ARROW_HALF_SIZE,
|
|
ARROW_SIZE,
|
|
BUBBLE_MAX_WIDTH,
|
|
MIN_EDGE_SPACE,
|
|
} from '#/components/Tooltip/const'
|
|
import {Text} from '#/components/Typography'
|
|
|
|
const TooltipPortal = createPortalGroup()
|
|
const TooltipProviderContext =
|
|
createContext<React.RefObject<View | null> | null>(null)
|
|
|
|
/**
|
|
* Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
|
|
* such as in the composer sheet.
|
|
*
|
|
* Only really necessary on iOS but can work on Android.
|
|
*/
|
|
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
|
|
const ref = useRef<View | null>(null)
|
|
return (
|
|
<GlobalGestureEventsProvider style={[a.flex_1]}>
|
|
<TooltipPortal.Provider>
|
|
<View ref={ref} collapsable={false} style={[a.flex_1]}>
|
|
<TooltipProviderContext value={ref}>
|
|
{children}
|
|
</TooltipProviderContext>
|
|
</View>
|
|
<TooltipPortal.Outlet />
|
|
</TooltipPortal.Provider>
|
|
</GlobalGestureEventsProvider>
|
|
)
|
|
}
|
|
SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
|
|
|
|
/**
|
|
* These are native specific values, not shared with web
|
|
*/
|
|
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
|
|
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
|
|
|
|
type TooltipContextType = {
|
|
position: 'top' | 'bottom'
|
|
visible: boolean
|
|
onVisibleChange: (visible: boolean) => void
|
|
}
|
|
|
|
type TargetMeasurements = {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
type TargetContextType = {
|
|
targetMeasurements: TargetMeasurements | undefined
|
|
setTargetMeasurements: (measurements: TargetMeasurements) => void
|
|
shouldMeasure: boolean
|
|
}
|
|
|
|
const TooltipContext = createContext<TooltipContextType>({
|
|
position: 'bottom',
|
|
visible: false,
|
|
onVisibleChange: () => {},
|
|
})
|
|
TooltipContext.displayName = 'TooltipContext'
|
|
|
|
const TargetContext = createContext<TargetContextType>({
|
|
targetMeasurements: undefined,
|
|
setTargetMeasurements: () => {},
|
|
shouldMeasure: false,
|
|
})
|
|
TargetContext.displayName = 'TargetContext'
|
|
|
|
export function Outer({
|
|
children,
|
|
position = 'bottom',
|
|
visible: requestVisible,
|
|
onVisibleChange,
|
|
}: {
|
|
children: React.ReactNode
|
|
position?: 'top' | 'bottom'
|
|
visible: boolean
|
|
onVisibleChange: (visible: boolean) => void
|
|
}) {
|
|
/**
|
|
* Lagging state to track the externally-controlled visibility of the
|
|
* tooltip, which needs to wait for the target to be measured before
|
|
* actually being shown.
|
|
*/
|
|
const [visible, setVisible] = useState<boolean>(false)
|
|
const [targetMeasurements, setTargetMeasurements] = useState<
|
|
| {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
| undefined
|
|
>(undefined)
|
|
|
|
if (requestVisible && !visible && targetMeasurements) {
|
|
setVisible(true)
|
|
} else if (!requestVisible && visible) {
|
|
setVisible(false)
|
|
setTargetMeasurements(undefined)
|
|
}
|
|
|
|
const ctx = useMemo(
|
|
() => ({position, visible, onVisibleChange}),
|
|
[position, visible, onVisibleChange],
|
|
)
|
|
const targetCtx = useMemo(
|
|
() => ({
|
|
targetMeasurements,
|
|
setTargetMeasurements,
|
|
shouldMeasure: requestVisible,
|
|
}),
|
|
[requestVisible, targetMeasurements, setTargetMeasurements],
|
|
)
|
|
|
|
return (
|
|
<TooltipContext.Provider value={ctx}>
|
|
<TargetContext.Provider value={targetCtx}>
|
|
{children}
|
|
</TargetContext.Provider>
|
|
</TooltipContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function Target({children}: {children: React.ReactNode}) {
|
|
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
|
const [hasLayedOut, setHasLayedOut] = useState(false)
|
|
const targetRef = useRef<View>(null)
|
|
const containerRef = useContext(TooltipProviderContext)
|
|
const keyboardIsOpen = useIsKeyboardVisible()
|
|
|
|
useEffect(() => {
|
|
if (!shouldMeasure || !hasLayedOut) return
|
|
/*
|
|
* Once opened, measure the dimensions and position of the target
|
|
*/
|
|
|
|
if (containerRef?.current) {
|
|
targetRef.current?.measureLayout(
|
|
containerRef.current,
|
|
(x, y, width, height) => {
|
|
if (x !== undefined && y !== undefined && width && height) {
|
|
setTargetMeasurements({x, y, width, height})
|
|
}
|
|
},
|
|
)
|
|
} else {
|
|
targetRef.current?.measure((_x, _y, width, height, x, y) => {
|
|
if (x !== undefined && y !== undefined && width && height) {
|
|
setTargetMeasurements({x, y, width, height})
|
|
}
|
|
})
|
|
}
|
|
}, [
|
|
shouldMeasure,
|
|
setTargetMeasurements,
|
|
hasLayedOut,
|
|
containerRef,
|
|
keyboardIsOpen,
|
|
])
|
|
|
|
return (
|
|
<View
|
|
collapsable={false}
|
|
ref={targetRef}
|
|
onLayout={() => setHasLayedOut(true)}>
|
|
{children}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function Content({
|
|
children,
|
|
label,
|
|
}: {
|
|
children: React.ReactNode
|
|
label: string
|
|
}) {
|
|
const {position, visible, onVisibleChange} = useContext(TooltipContext)
|
|
const {targetMeasurements} = useContext(TargetContext)
|
|
const isWithinProvider = !!useContext(TooltipProviderContext)
|
|
const requestClose = useCallback(() => {
|
|
onVisibleChange(false)
|
|
}, [onVisibleChange])
|
|
|
|
if (!visible || !targetMeasurements) return null
|
|
|
|
const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
|
|
|
|
return (
|
|
<Portal>
|
|
<Bubble
|
|
label={label}
|
|
position={position}
|
|
/*
|
|
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
|
|
* the context providers.
|
|
*/
|
|
targetMeasurements={targetMeasurements}
|
|
requestClose={requestClose}>
|
|
{children}
|
|
</Bubble>
|
|
</Portal>
|
|
)
|
|
}
|
|
|
|
function Bubble({
|
|
children,
|
|
label,
|
|
position,
|
|
requestClose,
|
|
targetMeasurements,
|
|
}: {
|
|
children: React.ReactNode
|
|
label: string
|
|
position: TooltipContextType['position']
|
|
requestClose: () => void
|
|
targetMeasurements: Exclude<
|
|
TargetContextType['targetMeasurements'],
|
|
undefined
|
|
>
|
|
}) {
|
|
const t = useTheme()
|
|
const insets = useSafeAreaInsets()
|
|
const dimensions = useWindowDimensions()
|
|
const [bubbleMeasurements, setBubbleMeasurements] = useState<
|
|
| {
|
|
width: number
|
|
height: number
|
|
}
|
|
| undefined
|
|
>(undefined)
|
|
const coords = useMemo(() => {
|
|
if (!bubbleMeasurements)
|
|
return {
|
|
top: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
tipTop: 0,
|
|
tipLeft: 0,
|
|
}
|
|
|
|
const {width: ww, height: wh} = dimensions
|
|
const maxTop = insets.top
|
|
const maxBottom = wh - insets.bottom
|
|
const {width: cw, height: ch} = bubbleMeasurements
|
|
const minLeft = MIN_EDGE_SPACE
|
|
const maxLeft = ww - minLeft
|
|
|
|
let computedPosition: 'top' | 'bottom' = position
|
|
let top = targetMeasurements.y + targetMeasurements.height
|
|
let left = Math.max(
|
|
minLeft,
|
|
targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
|
|
)
|
|
const tipTranslate = ARROW_HALF_SIZE * -1
|
|
let tipTop = tipTranslate
|
|
|
|
if (left + cw > maxLeft) {
|
|
left -= left + cw - maxLeft
|
|
}
|
|
|
|
let tipLeft =
|
|
targetMeasurements.x -
|
|
left +
|
|
targetMeasurements.width / 2 -
|
|
ARROW_HALF_SIZE
|
|
|
|
let bottom = top + ch
|
|
|
|
function positionTop() {
|
|
top = top - ch - targetMeasurements.height
|
|
bottom = top + ch
|
|
tipTop = tipTop + ch
|
|
computedPosition = 'top'
|
|
}
|
|
|
|
function positionBottom() {
|
|
top = targetMeasurements.y + targetMeasurements.height
|
|
bottom = top + ch
|
|
tipTop = tipTranslate
|
|
computedPosition = 'bottom'
|
|
}
|
|
|
|
if (position === 'top') {
|
|
positionTop()
|
|
if (top < maxTop) {
|
|
positionBottom()
|
|
}
|
|
} else {
|
|
if (bottom > maxBottom) {
|
|
positionTop()
|
|
}
|
|
}
|
|
|
|
if (computedPosition === 'bottom') {
|
|
top += ARROW_VISUAL_OFFSET
|
|
bottom += ARROW_VISUAL_OFFSET
|
|
} else {
|
|
top -= ARROW_VISUAL_OFFSET
|
|
bottom -= ARROW_VISUAL_OFFSET
|
|
}
|
|
|
|
return {
|
|
computedPosition,
|
|
top,
|
|
bottom,
|
|
left,
|
|
right: left + cw,
|
|
tipTop,
|
|
tipLeft,
|
|
}
|
|
}, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
|
|
|
|
const requestCloseWrapped = useCallback(() => {
|
|
setBubbleMeasurements(undefined)
|
|
requestClose()
|
|
}, [requestClose])
|
|
|
|
useOnGesture(
|
|
useCallback(
|
|
e => {
|
|
const {x, y} = e
|
|
const isInside =
|
|
x > coords.left &&
|
|
x < coords.right &&
|
|
y > coords.top &&
|
|
y < coords.bottom
|
|
|
|
if (!isInside) {
|
|
requestCloseWrapped()
|
|
}
|
|
},
|
|
[coords, requestCloseWrapped],
|
|
),
|
|
)
|
|
|
|
return (
|
|
<View
|
|
accessible
|
|
role="alert"
|
|
accessibilityHint=""
|
|
accessibilityLabel={label}
|
|
// android
|
|
importantForAccessibility="yes"
|
|
// ios
|
|
accessibilityViewIsModal
|
|
style={[
|
|
a.absolute,
|
|
a.align_start,
|
|
{
|
|
width: BUBBLE_MAX_WIDTH,
|
|
opacity: bubbleMeasurements ? 1 : 0,
|
|
top: coords.top,
|
|
left: coords.left,
|
|
},
|
|
]}>
|
|
<Animated.View
|
|
entering={ZoomIn.easing(Easing.out(Easing.exp))}
|
|
style={{transformOrigin: oppposite(position)}}>
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.top_0,
|
|
a.z_10,
|
|
t.atoms.bg,
|
|
select(t.name, {
|
|
light: t.atoms.bg,
|
|
dark: t.atoms.bg_contrast_100,
|
|
dim: t.atoms.bg_contrast_100,
|
|
}),
|
|
{
|
|
borderTopLeftRadius: a.rounded_2xs.borderRadius,
|
|
borderBottomRightRadius: a.rounded_2xs.borderRadius,
|
|
width: ARROW_SIZE,
|
|
height: ARROW_SIZE,
|
|
transform: [{rotate: '45deg'}],
|
|
top: coords.tipTop,
|
|
left: coords.tipLeft,
|
|
},
|
|
]}
|
|
/>
|
|
<View
|
|
style={[
|
|
a.px_md,
|
|
a.py_sm,
|
|
a.rounded_sm,
|
|
select(t.name, {
|
|
light: t.atoms.bg,
|
|
dark: t.atoms.bg_contrast_100,
|
|
dim: t.atoms.bg_contrast_100,
|
|
}),
|
|
t.atoms.shadow_md,
|
|
{
|
|
shadowOpacity: 0.2,
|
|
shadowOffset: {
|
|
width: 0,
|
|
height:
|
|
BUBBLE_SHADOW_OFFSET *
|
|
(coords.computedPosition === 'bottom' ? -1 : 1),
|
|
},
|
|
},
|
|
]}
|
|
onLayout={e => {
|
|
setBubbleMeasurements({
|
|
width: e.nativeEvent.layout.width,
|
|
height: e.nativeEvent.layout.height,
|
|
})
|
|
}}>
|
|
{children}
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function oppposite(position: 'top' | 'bottom') {
|
|
switch (position) {
|
|
case 'top':
|
|
return 'center bottom'
|
|
case 'bottom':
|
|
return 'center top'
|
|
default:
|
|
return 'center'
|
|
}
|
|
}
|
|
|
|
export function TextBubble({children}: {children: React.ReactNode}) {
|
|
const c = Children.toArray(children)
|
|
return (
|
|
<Content label={c.join(' ')}>
|
|
<View style={[a.gap_xs]}>
|
|
{c.map((child, i) => (
|
|
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
|
{child}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
</Content>
|
|
)
|
|
}
|