fix(plugins/settings.plugins): essential plugin tooltip showing up for pending updates + refactor tooltips

This commit is contained in:
PalmDevs 2026-08-12 18:02:56 +07:00
parent fc41d45f2a
commit 66465552bb
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
6 changed files with 189 additions and 186 deletions

View file

@ -21,11 +21,7 @@ import {
} from '../utils/sheets'
import { PluginIcon } from './PluginIcon'
import { usePluginEnabled } from './PluginStateProvider'
import {
useClickOutsideTooltip,
useEnablePluginTooltip,
useEssentialPluginTooltip,
} from './TooltipProvider'
import { PluginTooltip, usePluginTooltip } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
const { Card, Text, Stack, IconButton, Button, createStyles } = Design
@ -151,14 +147,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
const toggleDisabled = essential || pendingUpdate
const enableTooltip = useEnablePluginTooltip()
const essentialTooltip = useEssentialPluginTooltip()
const [settingsRef, showEnableTooltip] = usePluginTooltip(
PluginTooltip.Enable,
)
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
const switchRef = useClickOutsideTooltip(
useEssentialPluginTooltip,
() => {},
const [switchRef, showToggleTooltip] = usePluginTooltip(
essential ? PluginTooltip.Essential : PluginTooltip.PendingUpdate,
)
return (
@ -181,12 +175,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
{plugin.SettingsComponent && (
<Pressable
onPress={() => {
if (!startable)
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
if (!startable) showEnableTooltip()
}}
>
<IconButton
@ -203,12 +192,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
)}
<Pressable
onPress={() => {
if (toggleDisabled)
requestAnimationFrame(() => {
essentialTooltip.targetRef.current =
switchRef.current
essentialTooltip.setVisible(true)
})
if (toggleDisabled) showToggleTooltip()
}}
ref={switchRef}
>

View file

@ -9,7 +9,7 @@ import {
PLUGIN_CARD_HALF_GUTTER,
PluginCard,
} from './PluginCard'
import { useResetTooltips } from './TooltipProvider'
import { useHidePluginTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { FlashListProps } from '@shopify/flash-list'
@ -25,14 +25,14 @@ export function PluginFlashList({
FlashListProps<AnyPlugin>,
'onContentSizeChange'
>) {
const resetTooltips = useResetTooltips()
const hideTooltips = useHidePluginTooltips()
return (
<FlashList
style={gutterCompensation}
onContentSizeChange={onContentSizeChange}
data={plugins}
onScrollBeginDrag={resetTooltips}
onScrollBeginDrag={hideTooltips}
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
keyExtractor={plugin => plugin.manifest.id}
renderItem={({
@ -60,7 +60,7 @@ export function InstalledPluginMasonryFlashList({
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const numColumns = useNumColumns()
const resetTooltips = useResetTooltips()
const hideTooltips = useHidePluginTooltips()
return (
<FlashList
@ -69,7 +69,7 @@ export function InstalledPluginMasonryFlashList({
// FAB is 56px tall, plus 16px spacing on top and bottom
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
data={plugins}
onScrollBeginDrag={resetTooltips}
onScrollBeginDrag={hideTooltips}
fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id}
numColumns={numColumns}
@ -124,7 +124,7 @@ export function BrowsePluginMasonryFlashList({
onInstall: (entry: BrowseEntry) => void
}) {
const numColumns = useNumColumns()
const resetTooltips = useResetTooltips()
const hideTooltips = useHidePluginTooltips()
return (
<FlashList
@ -132,7 +132,7 @@ export function BrowsePluginMasonryFlashList({
style={gutterCompensation}
contentContainerStyle={{ paddingBottom: 16 }}
data={entries}
onScrollBeginDrag={resetTooltips}
onScrollBeginDrag={hideTooltips}
fadingEdgeLength={16}
keyExtractor={entry => entry.key}
numColumns={numColumns}

View file

@ -37,10 +37,9 @@ import {
import { messageOf, showErrorToast } from '../utils/repos'
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
import {
EnablePluginTooltipProvider,
useClickOutsideTooltip,
useEnablePluginTooltip,
import PluginTooltipsProvider, {
PluginTooltip,
usePluginTooltip,
} from './TooltipProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
@ -61,23 +60,44 @@ export default function PluginOptionsActionSheet({
plugin,
sheetKey,
}: PluginOptionsActionSheetProps) {
return (
<ActionSheet>
<ClickOutsideProvider>
<PluginTooltipsProvider>
<PluginOptions plugin={plugin} sheetKey={sheetKey} />
</PluginTooltipsProvider>
</ClickOutsideProvider>
</ActionSheet>
)
}
function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
const enabled = usePluginEnabled(plugin)
const meta = getInternalPluginMeta(plugin)
const essential = isPluginEssential(meta)
const pendingUpdate = isPluginPendingUpdate(plugin)
const { name, author, description, icon, version } = plugin.manifest
const [switchRef, showPendingUpdateTooltip] = usePluginTooltip(
PluginTooltip.PendingUpdate,
)
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
<PluginInfo
name={name}
author={author}
version={formatVersion(version)}
description={description}
icon={icon}
actions={
!essential && (
<Stack spacing={24} style={{ paddingTop: 8 }}>
<PluginInfo
name={name}
author={author}
version={formatVersion(version)}
description={description}
icon={icon}
actions={
!essential && (
<Pressable
onPress={() => {
if (pendingUpdate) showPendingUpdateTooltip()
}}
ref={switchRef}
>
<InstalledPluginSwitch
enabled={enabled}
plugin={plugin}
@ -86,25 +106,19 @@ export default function PluginOptionsActionSheet({
)}
toggleDisabled={pendingUpdate}
/>
)
}
/>
<ClickOutsideProvider>
<EnablePluginTooltipProvider>
<PluginActions
plugin={plugin}
closeSheet={() => {
ActionSheetActionCreators.hideActionSheet(
sheetKey,
)
}}
/>
</EnablePluginTooltipProvider>
</ClickOutsideProvider>
<StatusSection plugin={plugin} />
<AdvancedSection plugin={plugin} />
</Stack>
</ActionSheet>
</Pressable>
)
}
/>
<PluginActions
plugin={plugin}
closeSheet={() => {
ActionSheetActionCreators.hideActionSheet(sheetKey)
}}
/>
<StatusSection plugin={plugin} />
<AdvancedSection plugin={plugin} />
</Stack>
)
}
@ -272,8 +286,9 @@ function PluginActions({
plugin: AnyPlugin
closeSheet: () => void
}) {
const enableTooltip = useEnablePluginTooltip()
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
const [settingsRef, showEnableTooltip] = usePluginTooltip(
PluginTooltip.Enable,
)
const meta = getInternalPluginMeta(plugin)
const startable = isPluginStartable(plugin)
const enabled = usePluginEnabled(plugin)
@ -327,12 +342,7 @@ function PluginActions({
{plugin.SettingsComponent && (
<Pressable
onPress={() => {
if (!startable)
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
if (!startable) showEnableTooltip()
}}
>
<IconButton

View file

@ -1,109 +1,134 @@
import { Design } from '@revenge-mod/discord/design'
import { createContext, useContext, useRef, useState } from 'react'
import { useClickOutside } from 'react-native-click-outside'
import type { RefObject } from 'react'
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react'
import {
ClickOutsideProvider,
useClickOutside,
} from 'react-native-click-outside'
import type { View } from 'react-native'
const { useTooltip } = Design
interface TooltipContextValue {
targetRef: RefObject<View | null>
setVisible: (visible: boolean) => void
export const PluginTooltip = {
/** Shown when an action requires the plugin to be enabled first. */
Enable: 'enable',
/** Shown when an action is blocked because the plugin is essential. */
Essential: 'essential',
/** Shown when an action is blocked because the plugin has a pending update. */
PendingUpdate: 'pendingUpdate',
} as const
export type PluginTooltip = (typeof PluginTooltip)[keyof typeof PluginTooltip]
const Labels: Record<PluginTooltip, string> = {
[PluginTooltip.Enable]: 'Plugin must be enabled first',
[PluginTooltip.Essential]: 'Plugin needed for Revenge to function properly',
[PluginTooltip.PendingUpdate]:
'Plugin has a pending update, reload to apply it',
}
const EnablePluginTooltipContext = createContext<TooltipContextValue | null>(
null,
)
const EssentialPluginTooltipContext = createContext<TooltipContextValue | null>(
interface PluginTooltipsContextValue {
show: (tooltip: PluginTooltip, target: View | null) => void
hide: () => void
}
const PluginTooltipsContext = createContext<PluginTooltipsContextValue | null>(
null,
)
export function EnablePluginTooltipProvider({
export default function PluginTooltipsProvider({
children,
}: {
children: React.ReactNode
}) {
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
const [visible, setVisible] = useState<PluginTooltip | null>(null)
useTooltip(targetRef, {
label: 'Plugin must be enabled first',
// Targets are assigned by `show`, right before the tooltip becomes visible
const enableTarget = useRef<View | null>(null)
const essentialTarget = useRef<View | null>(null)
const pendingUpdateTarget = useRef<View | null>(null)
useTooltip(enableTarget, {
label: Labels[PluginTooltip.Enable],
position: 'top',
visible,
visible: visible === PluginTooltip.Enable,
})
return (
<EnablePluginTooltipContext.Provider value={{ targetRef, setVisible }}>
{children}
</EnablePluginTooltipContext.Provider>
)
}
export function EssentialPluginTooltipProvider({
children,
}: {
children: React.ReactNode
}) {
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
useTooltip(targetRef, {
label: 'Plugin needed for Revenge to function properly',
useTooltip(essentialTarget, {
label: Labels[PluginTooltip.Essential],
position: 'top',
visible,
visible: visible === PluginTooltip.Essential,
})
return (
<EssentialPluginTooltipContext.Provider
value={{ targetRef, setVisible }}
>
{children}
</EssentialPluginTooltipContext.Provider>
)
}
useTooltip(pendingUpdateTarget, {
label: Labels[PluginTooltip.PendingUpdate],
position: 'top',
visible: visible === PluginTooltip.PendingUpdate,
})
export function useEnablePluginTooltip() {
const context = useContext(EnablePluginTooltipContext)
if (!context) {
throw new Error(
'useEnablePluginTooltip must be used within EnablePluginTooltipProvider',
)
}
return context
}
export function useEssentialPluginTooltip() {
const context = useContext(EssentialPluginTooltipContext)
if (!context) {
throw new Error(
'useEssentialPluginTooltip must be used within EssentialPluginTooltipProvider',
)
}
return context
}
export function useResetTooltips() {
const enableTooltip = useContext(EnablePluginTooltipContext)
const essentialTooltip = useContext(EssentialPluginTooltipContext)
return () => {
enableTooltip?.setVisible(false)
essentialTooltip?.setVisible(false)
}
}
export function useClickOutsideTooltip(
contextHook: () => TooltipContextValue,
onClickOutside: () => void,
) {
const { targetRef, setVisible } = contextHook()
const ref = useClickOutside<View>(() => {
setVisible(false)
if (targetRef.current === ref.current) {
onClickOutside()
const value = useMemo<PluginTooltipsContextValue>(() => {
const targets = {
[PluginTooltip.Enable]: enableTarget,
[PluginTooltip.Essential]: essentialTarget,
[PluginTooltip.PendingUpdate]: pendingUpdateTarget,
}
})
return ref
return {
show: (tooltip, target) => {
targets[tooltip].current = target
setVisible(tooltip)
},
hide: () => setVisible(null),
}
}, [])
return (
<ClickOutsideProvider>
<PluginTooltipsContext.Provider value={value}>
{children}
</PluginTooltipsContext.Provider>
</ClickOutsideProvider>
)
}
/**
* Binds an element to a plugin tooltip.
*
* @param tooltip The tooltip to show for the element.
* @returns The ref to attach to the element, and a callback showing the tooltip on it.
*/
export function usePluginTooltip(tooltip: PluginTooltip) {
const context = useContext(PluginTooltipsContext)
if (!context)
throw new Error(
'usePluginTooltip must be used within PluginTooltipsProvider',
)
const { show, hide } = context
// Tapping anywhere else dismisses the tooltip
const ref = useClickOutside<View>(hide)
const showTooltip = useCallback(() => {
// Tapping the element itself also counts as a tap outside of it (the
// press target wraps the ref), so showing waits for the next frame
requestAnimationFrame(() => {
show(tooltip, ref.current)
})
}, [show, tooltip, ref])
return [ref, showTooltip] as const
}
/** Hides the visible plugin tooltip, if any.. */
export function useHidePluginTooltips() {
const context = useContext(PluginTooltipsContext)
return useCallback(() => context?.hide(), [context])
}

View file

@ -13,13 +13,9 @@ import {
import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import PluginTooltipsProvider from '../components/TooltipProvider'
import { runInstallFlow } from '../utils/repos'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
@ -34,17 +30,13 @@ const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() {
return (
<LayerScope>
<ClickOutsideProvider>
<PluginStatesProvider>
<Page spacing={16}>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
<PluginStatesProvider>
<Page spacing={16}>
<PluginTooltipsProvider>
<Screen />
</PluginTooltipsProvider>
</Page>
</PluginStatesProvider>
</LayerScope>
)
}

View file

@ -28,14 +28,10 @@ import {
useState,
} from 'react'
import { Image, View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import RevengeIcon from '~assets/RevengeIcon'
import { InstalledPluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import PluginTooltipsProvider from '../components/TooltipProvider'
import { RouteNames, Setting } from '../constants'
import type { NavigationProp, RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
@ -51,17 +47,13 @@ const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
export default function RevengePluginsSettingScreen() {
return (
<LayerScope>
<ClickOutsideProvider>
<PluginStatesProvider>
<PluginStatesProvider>
<PluginTooltipsProvider>
<Page spacing={16}>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
<Screen />
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</PluginTooltipsProvider>
</PluginStatesProvider>
</LayerScope>
)
}