diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx index 0d9122951..3bcd7ef6b 100644 --- a/src/screens/Login/index.tsx +++ b/src/screens/Login/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useRef} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {KeyboardAvoidingView} from 'react-native' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import {msg} from '@lingui/macro' @@ -49,9 +49,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { acc => acc.did === requestedAccountSwitchTo, ) - const [isResolvingService, setIsResolvingService] = React.useState(false) - const [error, setError] = React.useState('') - const [serviceUrl, setServiceUrl] = React.useState( + const [isResolvingService, setIsResolvingService] = useState(false) + const [error, setError] = useState('') + const [serviceUrl, setServiceUrl] = useState( requestedAccount?.service, ) const [initialHandle, setInitialHandle] = useState( diff --git a/src/state/invites.tsx b/src/state/invites.tsx new file mode 100644 index 000000000..4f12cb12f --- /dev/null +++ b/src/state/invites.tsx @@ -0,0 +1,59 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['invites'] +type ApiContext = { + setInviteCopied: (code: string) => void +} + +const stateContext = React.createContext( + persisted.defaults.invites, +) +stateContext.displayName = 'InvitesStateContext' +const apiContext = React.createContext({ + setInviteCopied(_: string) {}, +}) +apiContext.displayName = 'InvitesApiContext' + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('invites')) + + const api = React.useMemo( + () => ({ + setInviteCopied(code: string) { + setState(state => { + state = { + ...state, + copiedInvites: state.copiedInvites.includes(code) + ? state.copiedInvites + : state.copiedInvites.concat([code]), + } + persisted.write('invites', state) + return state + }) + }, + }), + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('invites', nextInvites => { + setState(nextInvites) + }) + }, [setState]) + + return ( + + {children} + + ) +} + +export function useInvitesState() { + return React.useContext(stateContext) +} + +export function useInvitesAPI() { + return React.useContext(apiContext) +} diff --git a/src/state/queries/invites.ts b/src/state/queries/invites.ts new file mode 100644 index 000000000..ed7fc534f --- /dev/null +++ b/src/state/queries/invites.ts @@ -0,0 +1,65 @@ +import {type ComAtprotoServerDefs} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' + +import {cleanError} from '#/lib/strings/errors' +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' + +function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { + return invite.available - invite.uses.length > 0 && !invite.disabled +} + +const inviteCodesQueryKeyRoot = 'inviteCodes' + +export type InviteCodesQueryResponse = Exclude< + ReturnType['data'], + undefined +> +export function useInviteCodesQuery() { + const agent = useAgent() + return useQuery({ + staleTime: STALE.MINUTES.FIVE, + queryKey: [inviteCodesQueryKeyRoot], + queryFn: async () => { + const res = await agent.com.atproto.server + .getAccountInviteCodes({}) + .catch(e => { + if (cleanError(e) === 'Bad token scope') { + return null + } else { + throw e + } + }) + + if (res === null) { + return { + disabled: true, + all: [], + available: [], + used: [], + } + } + + if (!res.data?.codes) { + throw new Error(`useInviteCodesQuery: no codes returned`) + } + + const available = res.data.codes.filter(isInviteAvailable) + const used = res.data.codes + .filter(code => !isInviteAvailable(code)) + .sort((a, b) => { + return ( + new Date(b.uses[0].usedAt).getTime() - + new Date(a.uses[0].usedAt).getTime() + ) + }) + + return { + disabled: false, + all: [...available, ...used], + available, + used, + } + }, + }) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 22b6b3d18..9bbe20d3f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -32,6 +32,7 @@ import Animated, { runOnUI, scrollTo, useAnimatedRef, + useAnimatedScrollHandler, useAnimatedStyle, useDerivedValue, useSharedValue, diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx deleted file mode 100644 index 37809320b..000000000 --- a/src/view/com/util/forms/NativeDropdown.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React from 'react' -import { - Platform, - Pressable, - StyleSheet, - type TextStyle, - View, - type ViewStyle, -} from 'react-native' -import {type IconProp} from '@fortawesome/fontawesome-svg-core' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import * as DropdownMenu from 'zeego/dropdown-menu' -import {type MenuItemCommonProps} from 'zeego/lib/typescript/menu' - -import {isIOS} from '#/platform/detection' -import {useTheme} from '#/alf' -import {useColorModeTheme} from '#/alf/util/useColorModeTheme' -import {Portal} from '#/components/Portal' - -// Custom Dropdown Menu Components -// == -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuRoot = DropdownMenu.Root -// export const DropdownMenuTrigger = DropdownMenu.Trigger -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuContent = DropdownMenu.Content - -type TriggerProps = Omit< - React.ComponentProps<(typeof DropdownMenu)['Trigger']>, - 'children' -> & - React.PropsWithChildren<{ - testID?: string - accessibilityLabel?: string - accessibilityHint?: string - }> -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuTrigger = DropdownMenu.create( - (props: TriggerProps) => { - const theme = useTheme() - - const defaultCtrlColor = theme.palette.contrast_500 - - return ( - // This Pressable doesn't actually do anything other than - // provide the "pressed state" visual feedback. - [{opacity: pressed ? 0.8 : 1}]}> - - - {props.children ? ( - props.children - ) : ( - - )} - - - - ) - }, - 'Trigger', -) - -type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']> -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuItem = DropdownMenu.create( - (props: ItemProps & {testID?: string}) => { - const theme = useTheme() - const colorMode = useColorModeTheme() - const [focused, setFocused] = React.useState(false) - const backgroundColor = - colorMode === 'light' ? theme.palette.black : theme.palette.white - - return ( - { - setFocused(true) - props.onFocus && props.onFocus() - }} - onBlur={() => { - setFocused(false) - props.onBlur && props.onBlur() - }} - /> - ) - }, - 'Item', -) - -type TitleProps = React.ComponentProps<(typeof DropdownMenu)['ItemTitle']> -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuItemTitle = DropdownMenu.create( - (props: TitleProps) => { - const theme = useTheme() - const colorMode = useColorModeTheme() - - return ( - - ) - }, - 'ItemTitle', -) - -type IconProps = React.ComponentProps<(typeof DropdownMenu)['ItemIcon']> -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuItemIcon = DropdownMenu.create((props: IconProps) => { - return -}, 'ItemIcon') - -type SeparatorProps = React.ComponentProps<(typeof DropdownMenu)['Separator']> -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export const DropdownMenuSeparator = DropdownMenu.create( - (props: SeparatorProps) => { - const theme = useTheme() - const colorMode = useColorModeTheme() - const {borderColor: separatorColor} = - colorMode === 'dark' - ? { - borderColor: theme.palette.contrast_200, - } - : { - borderColor: theme.palette.contrast_100, - } - return ( - - ) - }, - 'Separator', -) - -// Types for Dropdown Menu and Items -export type DropdownItem = { - label: string | 'separator' - onPress?: () => void - testID?: string - icon?: { - ios: MenuItemCommonProps['ios'] - android: string - web: IconProp - } -} -type Props = { - items: DropdownItem[] - testID?: string - accessibilityLabel?: string - accessibilityHint?: string - triggerStyle?: ViewStyle -} - -/** - * The `NativeDropdown` function uses native iOS and Android dropdown menus. - * It also creates a animated custom dropdown for web that uses - * Radix UI primitives under the hood - * @prop {DropdownItem[]} items - An array of dropdown items - * @prop {React.ReactNode} children - A custom dropdown trigger - * - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export function NativeDropdown({ - items, - children, - testID, - accessibilityLabel, - accessibilityHint, -}: React.PropsWithChildren) { - const theme = useTheme() - const colorMode = useColorModeTheme() - const [isOpen, setIsOpen] = React.useState(false) - const dropDownBackgroundColor = { - backgroundColor: theme.palette.contrast_25, - } - - const textStyle: TextStyle = { - color: colorMode === 'light' ? theme.palette.black : theme.palette.white, - } - - return ( - <> - {isIOS && isOpen && ( - - - - )} - - - {children} - - {/* @ts-ignore inheriting props from Radix, which is only for web */} - - {items.map((item, index) => { - if (item.label === 'separator') { - return ( - - ) - } - if (index > 1 && items[index - 1].label === 'separator') { - return ( - - - {item.label} - {item.icon && ( - - - - )} - - - ) - } - return ( - - {item.label} - {item.icon && ( - - - - )} - - ) - })} - - - - ) -} - -function Backdrop() { - // Not visible but it eats the click outside. - // Only necessary for iOS. - return ( - { - /* noop */ - }} - /> - ) -} - -const getKey = (label: string, index: number, id?: string) => { - if (id) { - return id - } - return `${label}_${index}` -} - -const styles = StyleSheet.create({ - separator: { - height: 1, - marginVertical: 4, - }, - content: { - backgroundColor: '#f0f0f0', - borderRadius: 8, - paddingVertical: 4, - paddingHorizontal: 4, - marginTop: 6, - ...Platform.select({ - web: { - animationDuration: '400ms', - animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)', - willChange: 'transform, opacity', - animationKeyframes: { - '0%': {opacity: 0, transform: [{scale: 0.5}]}, - '100%': {opacity: 1, transform: [{scale: 1}]}, - }, - boxShadow: - '0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)', - transformOrigin: 'var(--radix-dropdown-menu-content-transform-origin)', - }, - }), - }, - item: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - columnGap: 20, - // @ts-ignore -web - cursor: 'pointer', - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, - }, - itemTitle: { - fontSize: 18, - }, -}) diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx deleted file mode 100644 index a65908664..000000000 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import React from 'react' -import { - Pressable, - StyleSheet, - Text, - type TextStyle, - type View, - type ViewStyle, -} from 'react-native' -import {type IconProp} from '@fortawesome/fontawesome-svg-core' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {DropdownMenu} from 'radix-ui' -import {type MenuItemCommonProps} from 'zeego/lib/typescript/menu' - -import {HITSLOP_10} from '#/lib/constants' -import {useTheme} from '#/alf' -import {useColorModeTheme} from '#/alf/util/useColorModeTheme' - -// Custom Dropdown Menu Components -// == -export const DropdownMenuRoot = DropdownMenu.Root -export const DropdownMenuContent = DropdownMenu.Content - -type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']> -export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { - const [focused, setFocused] = React.useState(false) - const theme = useTheme() - const colorMode = useColorModeTheme() - const backgroundColor = - colorMode === 'light' ? theme.palette.black : theme.palette.white - - return ( - { - setFocused(true) - }} - onBlur={() => { - setFocused(false) - }} - /> - ) -} - -// Types for Dropdown Menu and Items -export type DropdownItem = { - label: string | 'separator' - onPress?: () => void - testID?: string - icon?: { - ios: MenuItemCommonProps['ios'] - android: string - web: IconProp - } -} -type Props = { - items: DropdownItem[] - testID?: string - accessibilityLabel?: string - accessibilityHint?: string - triggerStyle?: ViewStyle -} - -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export function NativeDropdown({ - items, - children, - testID, - accessibilityLabel, - accessibilityHint, - triggerStyle, -}: React.PropsWithChildren) { - const [open, setOpen] = React.useState(false) - const buttonRef = React.useRef(null) - const menuRef = React.useRef(null) - - React.useEffect(() => { - if (!open) { - return - } - - function clickHandler(e: MouseEvent) { - const t = e.target - - if (!open) return - if (!t) return - if (!buttonRef.current || !menuRef.current) return - - if ( - t !== buttonRef.current && - !buttonRef.current.contains(t as Node) && - t !== menuRef.current && - !menuRef.current.contains(t as Node) - ) { - // prevent clicking through to links beneath dropdown - // only applies to mobile web - e.preventDefault() - e.stopPropagation() - - // close menu - setOpen(false) - } - } - - function keydownHandler(e: KeyboardEvent) { - if (e.key === 'Escape' && open) { - setOpen(false) - } - } - - document.addEventListener('click', clickHandler, true) - window.addEventListener('keydown', keydownHandler, true) - return () => { - document.removeEventListener('click', clickHandler, true) - window.removeEventListener('keydown', keydownHandler, true) - } - }, [open, setOpen]) - - return ( - setOpen(o)}> - - } - testID={testID} - accessibilityRole="button" - accessibilityLabel={accessibilityLabel} - accessibilityHint={accessibilityHint} - onPointerDown={e => { - // Prevent false positive that interpret mobile scroll as a tap. - // This requires the custom onPress handler below to compensate. - // https://github.com/radix-ui/primitives/issues/1912 - e.preventDefault() - }} - onPress={() => { - if (window.event instanceof KeyboardEvent) { - // The onPointerDown hack above is not relevant to this press, so don't do anything. - return - } - // Compensate for the disabled onPointerDown above by triggering it manually. - setOpen(o => !o) - }} - hitSlop={HITSLOP_10} - style={triggerStyle}> - {children} - - - - - - - - ) -} - -function DropdownContent({ - items, - menuRef, -}: { - items: DropdownItem[] - menuRef: React.RefObject -}) { - const theme = useTheme() - const colorMode = useColorModeTheme() - const dropDownBackgroundColor = - colorMode === 'dark' - ? { - backgroundColor: theme.palette.contrast_25, - } - : { - backgroundColor: - colorMode === 'light' ? theme.palette.white : theme.palette.black, - } - const {borderColor: separatorColor} = - colorMode === 'dark' - ? { - borderColor: theme.palette.contrast_200, - } - : { - borderColor: theme.palette.contrast_100, - } - - const textStyle: TextStyle = { - color: colorMode === 'light' ? theme.palette.black : theme.palette.white, - } - - return ( - - {items.map((item, index) => { - if (item.label === 'separator') { - return ( - - ) - } - if (index > 1 && items[index - 1].label === 'separator') { - return ( - - - - {item.label} - - {item.icon && ( - - )} - - - ) - } - return ( - - - {item.label} - - {item.icon && ( - - )} - - ) - })} - - ) -} - -const getKey = (label: string, index: number, id?: string) => { - if (id) { - return id - } - return `${label}_${index}` -} - -const styles = StyleSheet.create({ - separator: { - height: 1, - marginTop: 4, - marginBottom: 4, - }, - content: { - backgroundColor: '#f0f0f0', - borderRadius: 8, - paddingTop: 4, - paddingBottom: 4, - paddingLeft: 4, - paddingRight: 4, - marginTop: 6, - - // @ts-ignore web only -prf - boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', - }, - item: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - columnGap: 20, - cursor: 'pointer', - paddingTop: 8, - paddingBottom: 8, - paddingLeft: 12, - paddingRight: 12, - borderRadius: 8, - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif', - // @ts-expect-error web only - outline: 0, - border: 0, - }, - itemTitle: { - fontSize: 16, - fontWeight: '600', - paddingRight: 10, - }, -})