Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 6fb0523f34 Session V2 2024-05-01 04:09:09 +01:00
100 changed files with 1872 additions and 2795 deletions
+4 -4
View File
@@ -20,8 +20,8 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Check
run: cd bskyweb/ && make check
- name: Build (binary)
@@ -37,7 +37,7 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Lint
run: cd bskyweb/ && make lint
-1
View File
@@ -40,7 +40,6 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs
# hack around issue with empty directory and go:embed
RUN touch bskyweb/static/js/empty.txt
RUN touch bskyweb/static/media/empty.txt
#
# Generate the embedr Go binary.
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 402 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 342 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 237 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

-2
View File
@@ -10,8 +10,6 @@ static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
static/js/empty.txt
static/media/*.png
static/media/empty.txt
templates/scripts.html
templates/*-embed.html
static/embed/*.html
+1 -1
View File
@@ -158,7 +158,7 @@ func serve(cctx *cli.Context) error {
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") {
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
View File
+1 -3
View File
@@ -15,7 +15,7 @@
"web": "expo start --web",
"use-build-number": "./scripts/useBuildNumberEnv.sh",
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/*.png ./bskyweb/static/media/",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number-with-bump eas build -p android",
@@ -50,7 +50,6 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto-labs/api": "^0.12.8-clipclops.0",
"@atproto/api": "^0.12.5",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
@@ -185,7 +184,6 @@
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.6.4",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
+15 -19
View File
@@ -18,7 +18,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications'
@@ -80,25 +79,22 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PushNotificationsListener>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
+15 -19
View File
@@ -8,7 +8,6 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
@@ -57,25 +56,22 @@ function InnerApp() {
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
+1 -1
View File
@@ -64,7 +64,7 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID' | 'onLongPress'
'disabled' | 'onPress' | 'testID'
> &
AccessibilityProps &
VariantProps & {
+1 -1
View File
@@ -72,7 +72,7 @@ export function Error({
a.text_center,
t.atoms.text_contrast_high,
{lineHeight: 1.4},
gtMobile ? {width: 450} : [a.w_full, a.px_lg],
gtMobile && {width: 450},
]}>
{message}
</Text>
+12 -14
View File
@@ -1,29 +1,27 @@
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
import {Context} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
ItemIconProps,
ItemProps,
ItemTextProps,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from 'platform/detection'
export {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export {useDialogControl as useMenuControl} from '#/components/Dialog'
export function useMemoControlContext() {
return React.useContext(Context)
+3 -10
View File
@@ -34,17 +34,10 @@ export function createPortalGroup() {
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
() => ({
outlet,
append,
remove,
}),
[outlet, append, remove],
)
return (
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
@@ -9,7 +9,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProfileShadow} from 'state/cache/profile-shadow'
-76
View File
@@ -1,76 +0,0 @@
import React, {useCallback} from 'react'
import {Pressable, View} from 'react-native'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {useHaptics} from 'lib/haptics'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export const ActionsWrapper = function GrowWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const playHaptic = useHaptics()
const menuControl = useMenuControl()
const scale = useSharedValue(1)
const animationDidComplete = useSharedValue(false)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}],
}))
const shrink = useCallback(() => {
'worklet'
cancelAnimation(scale)
scale.value = withTiming(1, {duration: 200}, () => {
animationDidComplete.value = false
})
}, [animationDidComplete, scale])
const grow = React.useCallback(() => {
'worklet'
scale.value = withTiming(1.05, {duration: 750}, finished => {
if (!finished) return
animationDidComplete.value = true
runOnJS(playHaptic)()
runOnJS(menuControl.open)()
shrink()
})
}, [scale, animationDidComplete, playHaptic, shrink, menuControl])
return (
<View
style={[
{
maxWidth: '65%',
},
isFromSelf ? a.self_end : a.self_start,
]}>
<AnimatedPressable
style={animatedStyle}
unstable_pressDelay={200}
onPressIn={grow}
onTouchEnd={shrink}>
{children}
</AnimatedPressable>
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
</View>
)
}
-81
View File
@@ -1,81 +0,0 @@
import React from 'react'
import {NativeSyntheticEvent, StyleSheet, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const menuControl = useMenuControl()
const viewRef = React.useRef(null)
const [showActions, setShowActions] = React.useState(false)
const onMouseEnter = React.useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = React.useCallback(() => {
setShowActions(false)
}, [])
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
const onFocus = React.useCallback((e: NativeSyntheticEvent<any>) => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
return (
<View
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
ref={viewRef}>
{isFromSelf && (
<View
style={[
a.mr_md,
a.justify_center,
{
marginLeft: 'auto',
},
]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
/>
</View>
)}
<View
style={{
maxWidth: '65%',
}}>
{children}
</View>
{!isFromSelf && (
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
/>
</View>
)}
</View>
)
}
-177
View File
@@ -1,177 +0,0 @@
import React, {useCallback} from 'react'
import {Pressable} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useMuteConvo,
useUnmuteConvo,
} from '#/state/queries/messages/mute-conversation'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
let ConvoMenu = ({
convo,
profile,
onUpdateConvo,
control,
hideTrigger,
currentScreen,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: AppBskyActorDefs.ProfileViewBasic
onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void
control?: Menu.MenuControlProps
hideTrigger?: boolean
currentScreen: 'list' | 'conversation'
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl()
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
const {mutate: muteConvo} = useMuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat muted`))
},
onError: () => {
Toast.show(_(msg`Could not mute chat`))
},
})
const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat unmuted`))
},
onError: () => {
Toast.show(_(msg`Could not unmute chat`))
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
onSuccess: () => {
if (currentScreen === 'conversation') {
navigation.replace('MessagesList')
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
},
})
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_sm,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="lg" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute notifications`)}
onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute notifications</Trans>
) : (
<Trans>Mute notifications</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
{/* TODO(samuel): implement these */}
<Menu.Group>
<Menu.Item
label={_(msg`Block account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Block account</Trans>
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.blocking ? PersonCheck : PersonX}
/>
</Menu.Item>
<Menu.Item
label={_(msg`Report account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Report account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={leaveConvoControl}
title={_(msg`Leave conversation`)}
description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={() => leaveConvo()}
/>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
-87
View File
@@ -1,87 +0,0 @@
import React, {useCallback} from 'react'
import {StyleProp, TextStyle} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function MessageItemMetadata({
message,
isLastInGroup,
style,
}: {
message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}) {
const t = useTheme()
const {_} = useLingui()
const relativeTimestamp = useCallback(
(timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date)
const diff = now.getTime() - date.getTime()
// if under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
// if in the last day
if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (
yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)
) {
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
},
[_],
)
if (!isLastInGroup) {
return null
}
return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_xs,
a.mb_lg,
style,
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
)
}
-86
View File
@@ -1,86 +0,0 @@
import React, {useMemo} from 'react'
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {MessageItemMetadata} from '#/components/dms/MesageItemMetadata'
import {Text} from '#/components/Typography'
export function MessageItem({
item,
next,
}: {
item: ChatBskyConvoDefs.MessageView
next:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}) {
const t = useTheme()
const {currentAccount} = useSession()
const isFromSelf = item.sender?.did === currentAccount?.did
const isNextFromSelf =
ChatBskyConvoDefs.isMessageView(next) &&
next.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => {
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 10 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(next)) {
const thisDate = new Date(item.sentAt)
const nextDate = new Date(next.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 10 minutes
return diff > 10 * 60 * 1000
}
return true
}, [item, next, isFromSelf, isNextFromSelf])
return (
<View>
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
<View
style={[
a.py_sm,
a.px_lg,
a.my_2xs,
a.rounded_md,
{
backgroundColor: isFromSelf
? t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<Text
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
]}>
{item.text}
</Text>
</View>
</ActionsWrapper>
<MessageItemMetadata
message={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left}
/>
</View>
)
}
-101
View File
@@ -1,101 +0,0 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
export let MessageMenu = ({
message,
control,
hideTrigger,
triggerOpacity,
}: {
hideTrigger?: boolean
triggerOpacity?: number
onTriggerPress?: () => void
message: ChatBskyConvoDefs.MessageView
control: Menu.MenuControlProps
}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const deleteControl = usePromptControl()
const isFromSelf = message.sender?.did === currentAccount?.did
const onDelete = React.useCallback(() => {
// TODO delete the message
}, [])
const onReport = React.useCallback(() => {
// TODO report the message
}, [])
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={{opacity: triggerOpacity}}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="sm" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message`)}
onPress={deleteControl.open}>
<Menu.ItemText>{_(msg`Delete`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
{!isFromSelf && (
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={onReport}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageMenu = React.memo(MessageMenu)
+6 -6
View File
@@ -8,8 +8,7 @@ import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useModerationOpts} from '#/state/queries/preferences'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
@@ -18,8 +17,9 @@ import {atoms as a, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query'
import {Button} from '../Button'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
import {ListMaybePlaceholder} from '../Lists'
import {Text} from '../Typography'
@@ -33,9 +33,9 @@ export function NewChat({
const t = useTheme()
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
const {mutate: createChat} = useGetChatFromMembers({
onSuccess: data => {
onNewChat(data.convo.id)
onNewChat(data.chat.id)
},
onError: error => {
Toast.show(error.message)
@@ -54,7 +54,7 @@ export function NewChat({
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
icon={<Envelope size="xl" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
})
-4
View File
@@ -3,7 +3,3 @@ import {createSinglePathSVG} from './TEMPLATE'
export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
})
export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z',
})
-87
View File
@@ -9,90 +9,3 @@ export function dedupArray<T>(arr: T[]): T[] {
const s = new Set(arr)
return [...s]
}
/**
* Taken from @tanstack/query-core utils.ts
* Modified to support Date object comparisons
*
* This function returns `a` if `b` is deeply equal.
* If not, it will replace any deeply equal children of `b` with those of `a`.
* This can be used for structural sharing between JSON values for example.
*/
export function replaceEqualDeep(a: any, b: any): any {
if (a === b) {
return a
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime() ? a : b
}
const array = isPlainArray(a) && isPlainArray(b)
if (array || (isPlainObject(a) && isPlainObject(b))) {
const aItems = array ? a : Object.keys(a)
const aSize = aItems.length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? [] : {}
let equalItems = 0
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i]
if (
!array &&
a[key] === undefined &&
b[key] === undefined &&
aItems.includes(key)
) {
copy[key] = undefined
equalItems++
} else {
copy[key] = replaceEqualDeep(a[key], b[key])
if (copy[key] === a[key] && a[key] !== undefined) {
equalItems++
}
}
}
return aSize === bSize && equalItems === aSize ? a : copy
}
return b
}
export function isPlainArray(value: unknown) {
return Array.isArray(value) && value.length === Object.keys(value).length
}
// Copied from: https://github.com/jonschlinkert/is-plain-object
export function isPlainObject(o: any): o is Object {
if (!hasObjectPrototype(o)) {
return false
}
// If has no constructor
const ctor = o.constructor
if (ctor === undefined) {
return true
}
// If has modified prototype
const prot = ctor.prototype
if (!hasObjectPrototype(prot)) {
return false
}
// If constructor does not have an Object-specific method
if (!prot.hasOwnProperty('isPrototypeOf')) {
return false
}
// Most likely a plain Object
return true
}
function hasObjectPrototype(o: any): boolean {
return Object.prototype.toString.call(o) === '[object Object]'
}
@@ -1,19 +1,8 @@
import React from 'react'
import {
Dimensions,
Keyboard,
NativeSyntheticEvent,
Pressable,
TextInput,
TextInputContentSizeChangeEventData,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Pressable, TextInput, View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {Text} from '#/components/Typography'
export function MessageInput({
onSendMessage,
@@ -24,67 +13,41 @@ export function MessageInput({
onFocus: () => void
onBlur: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const [message, setMessage] = React.useState('')
const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
const [isInputScrollable, setIsInputScrollable] = React.useState(false)
const {top: topInset} = useSafeAreaInsets()
const inputRef = React.useRef<TextInput>(null)
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
onSendMessage(message)
setMessage('')
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}, [message, onSendMessage])
const onInputLayout = React.useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
const keyboardHeight = Keyboard.metrics()?.height ?? 0
const windowHeight = Dimensions.get('window').height
const max = windowHeight - keyboardHeight - topInset - 100
const availableSpace = max - e.nativeEvent.contentSize.height
setMaxHeight(max)
setIsInputScrollable(availableSpace < 30)
},
[topInset],
)
return (
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
a.rounded_full,
a.mt_sm,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<TextInput
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Write a message`)}
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel="Text input field"
accessibilityHint="Write a message"
value={message}
multiline={true}
onChangeText={setMessage}
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
scrollEnabled={isInputScrollable}
blurOnSubmit={false}
placeholder="Write a message"
style={[a.flex_1, a.text_sm, a.px_sm, t.atoms.text]}
onSubmitEditing={onSubmit}
onFocus={onFocus}
onBlur={onBlur}
onContentSizeChange={onInputLayout}
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
ref={inputRef}
/>
<Pressable
@@ -96,7 +59,7 @@ export function MessageInput({
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} />
<Text style={a.text_md}>🐴</Text>
</Pressable>
</View>
)
@@ -1,91 +0,0 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import TextareaAutosize from 'react-textarea-autosize'
import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
export function MessageInput({
onSendMessage,
}: {
onSendMessage: (message: string) => void
onFocus: () => void
onBlur: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const [message, setMessage] = React.useState('')
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
setMessage('')
}, [message, onSendMessage])
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) return
e.preventDefault()
onSubmit()
}
},
[onSubmit],
)
const onChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
},
[],
)
return (
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
a.mt_sm,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<TextareaAutosize
style={StyleSheet.flatten([
a.flex_1,
a.px_sm,
a.border_0,
t.atoms.text,
{
backgroundColor: 'transparent',
resize: 'none',
paddingTop: 6,
},
])}
maxRows={12}
placeholder={_(msg`Write a message`)}
defaultValue=""
value={message}
dirName="ltr"
autoFocus={true}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<Pressable
accessibilityRole="button"
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}>
<PaperPlane fill={t.palette.white} />
</Pressable>
</View>
)
}
@@ -0,0 +1,158 @@
import React, {useCallback, useMemo} from 'react'
import {StyleProp, TextStyle, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgent} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
export function MessageItem({
item,
next,
}: {
item: TempDmChatDefs.MessageView
next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
}) {
const t = useTheme()
const {getAgent} = useAgent()
const isFromSelf = item.sender?.did === getAgent().session?.did
const isNextFromSelf =
TempDmChatDefs.isMessageView(next) &&
next.sender?.did === getAgent().session?.did
const isLastInGroup = useMemo(() => {
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 10 minute gap between this message and the next
if (TempDmChatDefs.isMessageView(next)) {
const thisDate = new Date(item.sentAt)
const nextDate = new Date(next.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 10 minutes
return diff > 10 * 60 * 1000
}
return true
}, [item, next, isFromSelf, isNextFromSelf])
return (
<View>
<View
style={[
a.py_sm,
a.px_lg,
a.my_2xs,
a.rounded_md,
isFromSelf ? a.self_end : a.self_start,
{
maxWidth: '65%',
backgroundColor: isFromSelf
? t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<Text
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
]}>
{item.text}
</Text>
</View>
<Metadata
message={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left}
/>
</View>
)
}
function Metadata({
message,
isLastInGroup,
style,
}: {
message: TempDmChatDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}) {
const t = useTheme()
const {_} = useLingui()
const relativeTimestamp = useCallback(
(timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date)
const diff = now.getTime() - date.getTime()
// under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
// in the last day
if (now.getDate() === date.getDate()) {
return time
}
// if yesterday
if (diff < 24 * 60 * 60 * 1000 && now.getDate() - date.getDate() === 1) {
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
},
[_],
)
if (!isLastInGroup) {
return null
}
return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_xs,
a.mb_lg,
style,
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
)
}
@@ -1,22 +1,24 @@
import React, {useCallback, useRef} from 'react'
import {
FlatList,
NativeScrollEvent,
NativeSyntheticEvent,
View,
} from 'react-native'
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {FlatList, View, ViewToken} from 'react-native'
import {Alert} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useChat} from '#/state/messages'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
import {isWeb} from 'platform/detection'
import {isWeb} from '#/platform/detection'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {Button, ButtonText} from '#/components/Button'
import {MessageItem} from '#/components/dms/MessageItem'
import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
import {
useChat,
useChatLogQuery,
useSendMessageMutation,
} from '#/screens/Messages/Temp/query/query'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
type MessageWithNext = {
message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
}
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
@@ -32,61 +34,82 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
)
}
function RetryButton({onPress}: {onPress: () => unknown}) {
const {_} = useLingui()
function renderItem({item}: {item: MessageWithNext}) {
if (TempDmChatDefs.isMessageView(item.message))
return <MessageItem item={item.message} next={item.next} />
return (
<View style={{alignItems: 'center'}}>
<Button
label={_(msg`Press to Retry`)}
onPress={onPress}
variant="ghost"
color="negative"
size="small">
<ButtonText>
<Trans>Press to Retry</Trans>
</ButtonText>
</Button>
</View>
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return <MessageItem item={item.message} next={item.nextMessage} />
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'pending-retry') {
return <RetryButton onPress={item.retry} />
}
if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text>
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
// TODO rm
// TEMP: This is a temporary function to generate unique keys for mutation placeholders
const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}`
function onScrollToEndFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList() {
const chat = useChat()
export function MessagesList({chatId}: {chatId: string}) {
const flatListRef = useRef<FlatList>(null)
// Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true
// once the request for new posts starts. Then, we will change it back to false after the content size changes.
const isFetching = useRef(false)
// We use this to know if we should scroll after a new clop is added to the list
const isAtBottom = useRef(false)
const currentOffset = React.useRef(0)
// Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the
// total number of clops
// TODO this needs to be set to whatever the initial number of messages is
const totalMessages = useRef(10)
// TODO later
const [_, setShowSpinner] = useState(false)
// Query Data
const {data: chat} = useChat(chatId)
const {mutate: sendMessage} = useSendMessageMutation(chatId)
useChatLogQuery()
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
return [
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
const firstVisibleIndex = info.viewableItems[0]?.index
isAtBottom.current = Number(firstVisibleIndex) < 2
},
{
itemVisiblePercentThreshold: 50,
minimumViewTime: 10,
},
]
}, [])
const onContentSizeChange = useCallback(() => {
if (currentOffset.current <= 100) {
if (isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
}
isFetching.current = false
setShowSpinner(false)
}, [])
const onEndReached = useCallback(() => {
chat.service.fetchMessageHistory()
}, [chat])
if (isFetching.current) return
isFetching.current = true
setShowSpinner(true)
// Eventually we will add more here when we hit the top through RQuery
// We wouldn't actually use a timeout, but there would be a delay while loading
setTimeout(() => {
// Do something
setShowSpinner(false)
}, 1000)
}, [])
const onInputFocus = useCallback(() => {
if (!isAtBottom.current) {
@@ -94,23 +117,49 @@ export function MessagesList() {
}
}, [])
const onSendMessage = useCallback(
async (message: string) => {
if (!message) return
try {
sendMessage({
message,
tempId: generateUniqueKey(),
})
} catch (e: any) {
Alert.alert(e.toString())
}
},
[sendMessage],
)
const onInputBlur = useCallback(() => {}, [])
const onSendMessage = useCallback(
(text: string) => {
chat.service.sendMessage({
text,
})
},
[chat.service],
)
const messages = useMemo(() => {
if (!chat) return []
const onScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
currentOffset.current = e.nativeEvent.contentOffset.y
},
[],
)
const filtered = chat.messages
.filter(
(
message,
): message is
| TempDmChatDefs.MessageView
| TempDmChatDefs.DeletedMessage => {
return (
TempDmChatDefs.isMessageView(message) ||
TempDmChatDefs.isDeletedMessage(message)
)
},
)
.reduce((acc, message) => {
// convert [n1, n2, n3, ...] to [{message: n1, next: n2}, {message: n2, next: n3}, {message: n3, next: n4}, ...]
return [...acc, {message, next: acc.at(-1)?.message ?? null}]
}, [] as MessageWithNext[])
totalMessages.current = filtered.length
return filtered
}, [chat])
return (
<KeyboardAvoidingView
@@ -119,41 +168,30 @@ export function MessagesList() {
keyboardVerticalOffset={70}
contentContainerStyle={{flex: 1}}>
<FlatList
data={
chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined
}
keyExtractor={keyExtractor}
data={messages}
keyExtractor={item => item.message.id}
renderItem={renderItem}
contentContainerStyle={{paddingHorizontal: 10}}
inverted={true}
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only
// dealing with text. But whenever we have images or other media and things are taller, we will want to lower
// this...probably.
initialNumToRender={20}
// Same with the max to render per batch. Let's be safe for now though.
maxToRenderPerBatch={25}
inverted={true}
removeClippedSubviews={true}
onEndReached={onEndReached}
onScrollToIndexFailed={onScrollToEndFailed}
onContentSizeChange={onContentSizeChange}
onScroll={onScroll}
// We don't really need to call this much since there are not any animations that rely on this
scrollEventThrottle={100}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
maintainVisibleContentPosition={{
minIndexForVisible: 1,
}}
ListFooterComponent={
<MaybeLoader
isLoading={
chat.state.status === ConvoStatus.Ready &&
chat.state.isFetchingHistory
}
/>
}
removeClippedSubviews={true}
ListFooterComponent={<MaybeLoader isLoading={false} />}
ref={flatListRef}
keyboardDismissMode="none"
/>
<View style={{paddingHorizontal: 10}}>
<MessageInput
onSendMessage={onSendMessage}
+36 -59
View File
@@ -1,7 +1,6 @@
import React, {useCallback} from 'react'
import React from 'react'
import {TouchableOpacity, View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,14 +11,14 @@ import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {BACK_HITSLOP} from 'lib/constants'
import {isWeb} from 'platform/detection'
import {ChatProvider, useChat} from 'state/messages'
import {ConvoStatus} from 'state/messages/convo'
import {useSession} from 'state/session'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {useChatQuery} from '#/screens/Messages/Temp/query/query'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Button, ButtonIcon} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
@@ -30,57 +29,40 @@ type Props = NativeStackScreenProps<
>
export function MessagesConversationScreen({route}: Props) {
const gate = useGate()
const convoId = route.params.conversation
if (!gate('dms')) return <ClipClopGate />
return (
<ChatProvider convoId={convoId}>
<Inner />
</ChatProvider>
)
}
function Inner() {
const chat = useChat()
const chatId = route.params.conversation
const {currentAccount} = useSession()
const myDid = currentAccount?.did
const {data: chat, isError: isError} = useChatQuery(chatId)
const otherProfile = React.useMemo(() => {
if (chat.state.status !== ConvoStatus.Ready) return
return chat.state.convo.members.find(m => m.did !== myDid)
}, [chat.state, myDid])
return chat?.members?.find(m => m.did !== myDid)
}, [chat?.members, myDid])
// TODO whenever we have error messages, we should use them in here -hailey
if (chat.state.status !== ConvoStatus.Ready || !otherProfile) {
if (!gate('dms')) return <ClipClopGate />
if (!chat || !otherProfile) {
return (
<ListMaybePlaceholder
isLoading={true}
isError={chat.state.status === ConvoStatus.Error}
/>
<CenteredView style={{flex: 1}} sideBorders>
<ListMaybePlaceholder isLoading={true} isError={isError} />
</CenteredView>
)
}
return (
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={otherProfile} />
<MessagesList />
<MessagesList chatId={chatId} />
</CenteredView>
)
}
let Header = ({
profile,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
}): React.ReactNode => {
function Header({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const {service} = useChat()
const onPressBack = useCallback(() => {
const onPressBack = React.useCallback(() => {
if (isWeb) {
navigation.replace('MessagesList')
} else {
@@ -88,13 +70,6 @@ let Header = ({
}
}, [navigation])
const onUpdateConvo = useCallback(
(convo: ChatBskyConvoDefs.ConvoView) => {
service.convo = convo
},
[service],
)
return (
<View
style={[
@@ -103,20 +78,22 @@ let Header = ({
a.border_b,
a.flex_row,
a.justify_between,
a.align_start,
a.gap_lg,
a.px_lg,
a.py_sm,
]}>
{!gtTablet ? (
<TouchableOpacity
testID="conversationHeaderBackBtn"
testID="viewHeaderDrawerBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{width: 30, height: 30}}
style={{
width: 30,
height: 30,
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
accessibilityHint={_(msg`Access navigation links and settings`)}>
<FontAwesomeIcon
size={18}
icon="angle-left"
@@ -130,22 +107,22 @@ let Header = ({
<View style={{width: 30}} />
)}
<View style={[a.align_center, a.gap_sm]}>
<PreviewableUserAvatar size={32} profile={profile} />
<UserAvatar size={32} avatar={profile.avatar} />
<Text style={[a.text_lg, a.font_bold]}>
<Trans>{profile.displayName}</Trans>
</Text>
</View>
{service.convo ? (
<ConvoMenu
convo={service.convo}
profile={profile}
onUpdateConvo={onUpdateConvo}
currentScreen="conversation"
/>
) : (
<View style={{width: 30}} />
)}
<View>
<Button
label={_(msg`Chat settings`)}
color="secondary"
size="large"
variant="ghost"
style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
onPress={() => {}}>
<ButtonIcon icon={DotGrid_Stroke2_Corner0_Rounded} />
</Button>
</View>
</View>
)
}
Header = React.memo(Header)
+34 -99
View File
@@ -2,7 +2,6 @@
import React, {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -12,26 +11,23 @@ import {MessagesTabNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useListConvos} from '#/state/queries/messages/list-converations'
import {useSession} from '#/state/session'
import {useAgent} from '#/state/session'
import {List} from '#/view/com/util/List'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {useBreakpoints, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {NewChat} from '#/components/dms/NewChat'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import {Link} from '#/components/Link'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useMenuControl} from '#/components/Menu'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
import {NewChat} from '../../../components/dms/NewChat'
import {ClipClopGate} from '../gate'
import {useListChats} from '../Temp/query/query'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
export function MessagesListScreen({navigation}: Props) {
@@ -62,13 +58,13 @@ export function MessagesListScreen({navigation}: Props) {
fetchNextPage,
error,
refetch,
} = useListConvos()
} = useListChats()
const isError = !!error
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.convos)
return data.pages.flatMap(page => page.chats)
}
return []
}, [data])
@@ -102,48 +98,28 @@ export function MessagesListScreen({navigation}: Props) {
navigation.navigate('MessagesSettings')
}, [navigation])
const renderItem = useCallback(
({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
return <ChatListItem key={item.id} convo={item} />
},
[],
)
const renderItem = useCallback(({item}: {item: TempDmChatDefs.ChatView}) => {
return <ChatListItem key={item.id} chat={item} />
}, [])
const gate = useGate()
if (!gate('dms')) return <ClipClopGate />
if (conversations.length < 1) {
return (
<View style={a.flex_1}>
{gtMobile ? (
<CenteredView sideBorders>
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
</CenteredView>
) : (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
{!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />}
<>
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
emptyType="results"
emptyTitle={_(msg`No messages yet`)}
emptyMessage={_(
msg`You have no messages yet. Start a conversation with someone!`,
)}
errorMessage={cleanError(error)}
onRetry={isError ? refetch : undefined}
hideBackButton
/>
</View>
<NewChat onNewChat={onNewChat} control={newChatControl} />
</>
)
}
@@ -189,28 +165,18 @@ export function MessagesListScreen({navigation}: Props) {
)
}
function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const {getAgent} = useAgent()
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
if (convo.lastMessage.sender?.did === currentAccount?.did) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
}
lastMessageSentAt = convo.lastMessage.sentAt
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = _(msg`Message deleted`)
if (TempDmChatDefs.isMessageView(chat.lastMessage)) {
lastMessage = chat.lastMessage.text
}
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
const otherUser = chat.members.find(
member => member.did !== getAgent().session?.did,
)
if (!otherUser) {
@@ -218,10 +184,7 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
}
return (
<Link
to={`/messages/${convo.id}`}
style={a.flex_1}
onLongPress={isNative ? menuControl.open : undefined}>
<Link to={`/messages/${chat.id}`} style={a.flex_1}>
{({hovered, pressed}) => (
<View
style={[
@@ -237,63 +200,35 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
<PreviewableUserAvatar profile={otherUser} size={42} />
</View>
<View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
<Text
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
<Text numberOfLines={1} style={a.leading_snug}>
<Text style={[t.atoms.text, chat.unreadCount > 0 && a.font_bold]}>
{otherUser.displayName || otherUser.handle}
</Text>{' '}
{lastMessageSentAt ? (
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle} &middot; {timeElapsed}
</Text>
)}
</TimeElapsed>
) : (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle}
</Text>
)}
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle}
</Text>
</Text>
<Text
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
convo.unreadCount > 0
? a.font_bold
: t.atoms.text_contrast_medium,
chat.unread ? a.font_bold : t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
</View>
{convo.unreadCount > 0 && (
{chat.unreadCount > 0 && (
<View
style={[
a.flex_0,
a.ml_md,
a.mt_sm,
a.ml_2xl,
a.mt_xs,
{backgroundColor: t.palette.primary_500},
a.rounded_full,
{
backgroundColor: convo.muted
? t.palette.contrast_200
: t.palette.primary_500,
height: 7,
width: 7,
},
{height: 7, width: 7},
]}
/>
)}
<ConvoMenu
convo={convo}
profile={otherUser}
control={menuControl}
// TODO(sam) show on hover on web
// tricky because it captures the mouse event
hideTrigger
currentScreen="list"
/>
</View>
)}
</Link>
@@ -349,7 +284,7 @@ function DesktopHeader({
variant="solid"
style={[{height: 'auto', width: 'auto'}, a.px_md, a.py_sm]}
onPress={newChatControl.open}>
<ButtonIcon icon={Plus} position="right" />
<ButtonIcon icon={Envelope} position="right" />
<ButtonText>
<Trans>New chat</Trans>
</ButtonText>
+301
View File
@@ -0,0 +1,301 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
import * as TempDmChatDefs from '#/temp/dm/defs'
import * as TempDmChatGetChat from '#/temp/dm/getChat'
import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers'
import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
import * as TempDmChatListChats from '#/temp/dm/listChats'
import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
/**
* TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
* (and do not try this at home)
*/
const useHeaders = () => {
const {getAgent} = useAgent()
return {
get Authorization() {
return getAgent().session!.did
},
}
}
type Chat = {
chatId: string
messages: TempDmChatGetChatMessages.OutputSchema['messages']
lastCursor?: string
lastRev?: string
}
export function useChat(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chat', chatId],
queryFn: async () => {
const currentChat = queryClient.getQueryData(['chat', chatId])
if (currentChat) {
return currentChat as Chat
}
const messagesResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`,
{
headers,
},
)
if (!messagesResponse.ok) throw new Error('Failed to fetch messages')
const messagesJson =
(await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema
const chatResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
{
headers,
},
)
if (!chatResponse.ok) throw new Error('Failed to fetch chat')
const chatJson =
(await chatResponse.json()) as TempDmChatGetChat.OutputSchema
queryClient.setQueryData(['chatQuery', chatId], chatJson.chat)
const newChat = {
chatId,
messages: messagesJson.messages,
lastCursor: messagesJson.cursor,
lastRev: chatJson.chat.rev,
} satisfies Chat
queryClient.setQueryData(['chat', chatId], newChat)
return newChat
},
})
}
interface SendMessageMutationVariables {
message: string
tempId: string
}
export function createTempId() {
return Math.random().toString(36).substring(7).toString()
}
export function useSendMessageMutation(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation<
TempDmChatDefs.Message,
Error,
SendMessageMutationVariables,
unknown
>({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mutationFn: async ({message, tempId}) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
chatId,
message: {
text: message,
},
}),
},
)
if (!response.ok) throw new Error('Failed to send message')
return response.json()
},
onMutate: async variables => {
queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
return {
...prev,
messages: [
{
$type: 'temp.dm.defs#messageView',
id: variables.tempId,
text: variables.message,
sender: {did: headers.Authorization}, // TODO a real DID get
sentAt: new Date().toISOString(),
},
...prev.messages,
],
}
})
},
onSuccess: (result, variables) => {
queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
return {
...prev,
messages: prev.messages.map(m =>
m.id === variables.tempId ? {...m, id: result.id} : m,
),
}
})
},
onError: (_, variables) => {
console.log(_)
queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({
...prev,
messages: prev.messages.filter(m => m.id !== variables.tempId),
}))
},
})
}
export function useChatLogQuery() {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chatLog'],
queryFn: async () => {
const prevLog = queryClient.getQueryData([
'chatLog',
]) as TempDmChatGetChatLog.OutputSchema
try {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${
prevLog?.cursor ?? ''
}`,
{
headers,
},
)
if (!response.ok) throw new Error('Failed to fetch chat log')
const json =
(await response.json()) as TempDmChatGetChatLog.OutputSchema
for (const log of json.logs) {
if (TempDmChatDefs.isLogCreateMessage(log)) {
queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => {
// TODO hack filter out duplicates
if (prev?.messages.find(m => m.id === log.message.id)) return
return {
...prev,
messages: [log.message, ...prev.messages],
}
})
}
}
return json
} catch (e) {
console.log(e)
}
},
refetchInterval: 5000,
})
}
export function useGetChatFromMembers({
onSuccess,
onError,
}: {
onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async (members: string[]) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join(
',',
)}`,
{headers},
)
if (!response.ok) throw new Error('Failed to fetch chat')
return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
},
onSuccess: data => {
queryClient.setQueryData(['chat', data.chat.id], {
chatId: data.chat.id,
messages: [],
lastRev: data.chat.rev,
})
onSuccess?.(data)
},
onError,
})
}
export function useListChats() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useInfiniteQuery({
queryKey: ['chats'],
queryFn: async ({pageParam}) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.listChats${
pageParam ? `?cursor=${pageParam}` : ''
}`,
{headers},
)
if (!response.ok) throw new Error('Failed to fetch chats')
return (await response.json()) as TempDmChatListChats.OutputSchema
},
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function useChatQuery(chatId: string) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chatQuery', chatId],
queryFn: async () => {
const chatResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
{
headers,
},
)
if (!chatResponse.ok) throw new Error('Failed to fetch chat')
const json = (await chatResponse.json()) as TempDmChatGetChat.OutputSchema
return json.chat
},
})
}
@@ -2,13 +2,13 @@ import React from 'react'
import {View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, flatten, useTheme} from '#/alf'
import {useTheme, atoms as a, flatten} from '#/alf'
import {Text} from '#/components/Typography'
import {useItemContext} from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useModerationOpts} from '#/state/queries/preferences'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
export function SuggestedAccountCard({
profile,
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfilesQuery} from '#/state/queries/profile'
import {
DescriptionText,
@@ -26,7 +26,7 @@ export function CaptchaWebView({
stateParam: string
state?: SignupState
onSuccess: (code: string) => void
onError: (error: unknown) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!state?.serviceUrl) return 'bsky.app'
@@ -56,7 +56,7 @@ export function CaptchaWebView({
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError({error: 'Invalid state or code'})
onError()
return
}
@@ -74,12 +74,6 @@ export function CaptchaWebView({
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
onError={e => {
onError(e.nativeEvent)
}}
onHttpError={e => {
onError(e.nativeEvent)
}}
/>
)
}
@@ -13,20 +13,8 @@ export function CaptchaWebView({
url: string
stateParam: string
onSuccess: (code: string) => void
onError: (error: unknown) => void
onError: () => void
}) {
React.useEffect(() => {
const timeout = setTimeout(() => {
onError({
errorMessage: 'User did not complete the captcha within 30 seconds',
})
}, 30e3)
return () => {
clearTimeout(timeout)
}
}, [onError])
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
@@ -44,14 +32,12 @@ export function CaptchaWebView({
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError({error: 'Invalid state or code'})
onError()
return
}
onSuccess(code)
} catch (e: unknown) {
// We don't actually want to record an error here, because this will happen quite a bit. We will only be able to
// get hte href of the iframe if it's on our domain, so all the hcaptcha requests will throw here, although it's
// harmless. Our other indicators of time-to-complete and back press should be more reliable in catching issues.
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
+6 -14
View File
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
@@ -44,19 +43,12 @@ export function StepCaptcha() {
[submit],
)
const onError = React.useCallback(
(error?: unknown) => {
dispatch({
type: 'setError',
value: _(msg`Error receiving captcha response.`),
})
logger.error('Signup Flow Error', {
registrationHandle: state.handle,
error,
})
},
[_, dispatch, state.handle],
)
const onError = React.useCallback(() => {
dispatch({
type: 'setError',
value: _(msg`Error receiving captcha response.`),
})
}, [_, dispatch])
return (
<ScreenTransition>
+1 -10
View File
@@ -8,7 +8,6 @@ import {useAnalytics} from '#/lib/analytics/analytics'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
import {useAgent} from '#/state/session'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
@@ -120,19 +119,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
const onBackPress = React.useCallback(() => {
if (state.activeStep !== SignupStep.INFO) {
if (state.activeStep === SignupStep.CAPTCHA) {
logger.error('Signup Flow Error', {
errorMessage:
'User went back from captcha step. Possibly encountered an error.',
registrationHandle: state.handle,
})
}
dispatch({type: 'prev'})
} else {
onPressBack()
}
}, [onPressBack, state.activeStep, state.handle])
}, [onPressBack, state.activeStep])
return (
<SignupContext.Provider value={{state, dispatch}}>
+9 -10
View File
@@ -246,10 +246,6 @@ export function useSubmitSignup({
!verificationCode
) {
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
logger.error('Signup Flow Error', {
errorMessage: 'Verification captcha code was not set.',
registrationHandle: state.handle,
})
return dispatch({
type: 'setError',
value: _(msg`Please complete the verification captcha.`),
@@ -286,17 +282,20 @@ export function useSubmitSignup({
return
}
if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {message: e})
} else {
logger.error(`Failed to create account (${e.status} status)`, {
message: e,
})
}
const error = cleanError(errMsg)
const isHandleError = error.toLowerCase().includes('handle')
dispatch({type: 'setIsLoading', value: false})
dispatch({type: 'setError', value: error})
dispatch({type: 'setError', value: cleanError(errMsg)})
dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
logger.error('Signup Flow Error', {
errorMessage: error,
registrationHandle: state.handle,
})
} finally {
dispatch({type: 'setIsLoading', value: false})
}
@@ -1,57 +0,0 @@
import {describe, it} from '@jest/globals'
describe(`#/state/messages/convo`, () => {
describe(`status states`, () => {
it.todo(`cannot re-initialize from a non-unintialized state`)
it.todo(`can re-initialize from a failed state`)
describe(`destroy`, () => {
it.todo(`cannot be interacted with when destroyed`)
it.todo(`polling is stopped when destroyed`)
it.todo(`events are cleaned up when destroyed`)
})
})
describe(`history fetching`, () => {
it.todo(`fetches initial chat history`)
it.todo(`fetches additional chat history`)
it.todo(`handles history fetch failure`)
it.todo(`does not insert deleted messages`)
})
describe(`sending messages`, () => {
it.todo(`optimistically adds sending messages`)
it.todo(`sends messages in order`)
it.todo(`failed message send fails all sending messages`)
it.todo(`can retry all failed messages via retry ConvoItem`)
it.todo(
`successfully sent messages are re-ordered, if needed, by events received from server`,
)
})
describe(`deleting messages`, () => {
it.todo(`messages are optimistically deleted from the chat`)
it.todo(`messages are confirmed deleted via events from the server`)
})
describe(`log handling`, () => {
it.todo(`updates rev to latest message received`)
it.todo(`only handles log events for this convoId`)
it.todo(`does not insert deleted messages`)
})
describe(`item ordering`, () => {
it.todo(`pending items are first, and in order`)
it.todo(`new message items are next, and in order`)
it.todo(`past message items are next, and in order`)
})
describe(`inactivity`, () => {
it.todo(
`below a certain threshold of inactivity, restore entirely from log`,
)
it.todo(
`above a certain threshold of inactivity, rehydrate entirely fresh state`,
)
})
})
-561
View File
@@ -1,561 +0,0 @@
import {AppBskyActorDefs} from '@atproto/api'
import {
BskyAgent,
ChatBskyConvoDefs,
ChatBskyConvoSendMessage,
} from '@atproto-labs/api'
import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {isNative} from '#/platform/detection'
export type ConvoParams = {
convoId: string
agent: BskyAgent
__tempFromUserDid: string
}
export enum ConvoStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Ready = 'ready',
Error = 'error',
Destroyed = 'destroyed',
}
export type ConvoItem =
| {
type: 'message' | 'pending-message'
key: string
message: ChatBskyConvoDefs.MessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'deleted-message'
key: string
message: ChatBskyConvoDefs.DeletedMessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'pending-retry'
key: string
retry: () => void
}
export type ConvoState =
| {
status: ConvoStatus.Uninitialized
}
| {
status: ConvoStatus.Initializing
}
| {
status: ConvoStatus.Ready
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
isFetchingHistory: boolean
}
| {
status: ConvoStatus.Error
error: any
}
| {
status: ConvoStatus.Destroyed
}
export function isConvoItemMessage(
item: ConvoItem,
): item is ConvoItem & {type: 'message'} {
if (!item) return false
return (
item.type === 'message' ||
item.type === 'deleted-message' ||
item.type === 'pending-message'
)
}
export class Convo {
private agent: BskyAgent
private __tempFromUserDid: string
private status: ConvoStatus = ConvoStatus.Uninitialized
private error: any
private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false
private eventsCursor: string | undefined = undefined
convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined
sender: AppBskyActorDefs.ProfileViewBasic | undefined
private pastMessages: Map<
string,
ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
> = new Map()
private newMessages: Map<
string,
ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
> = new Map()
private pendingMessages: Map<
string,
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map()
private footerItems: Map<string, ConvoItem> = new Map()
private pendingEventIngestion: Promise<void> | undefined
private isProcessingPendingMessages = false
constructor(params: ConvoParams) {
this.convoId = params.convoId
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
}
async initialize() {
if (this.status !== 'uninitialized') return
this.status = ConvoStatus.Initializing
try {
const response = await this.agent.api.chat.bsky.convo.getConvo(
{
convoId: this.convoId,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {convo} = response.data
this.convo = convo
this.sender = this.convo.members.find(
m => m.did === this.__tempFromUserDid,
)
this.status = ConvoStatus.Ready
this.commit()
await this.fetchMessageHistory()
this.pollEvents()
} catch (e) {
this.status = ConvoStatus.Error
this.error = e
}
}
private async pollEvents() {
if (this.status === ConvoStatus.Destroyed) return
if (this.pendingEventIngestion) return
setTimeout(async () => {
this.pendingEventIngestion = this.ingestLatestEvents()
await this.pendingEventIngestion
this.pendingEventIngestion = undefined
this.pollEvents()
}, 5e3)
}
async fetchMessageHistory() {
if (this.status === ConvoStatus.Destroyed) return
// reached end
if (this.historyCursor === null) return
if (this.isFetchingHistory) return
this.isFetchingHistory = true
this.commit()
/*
* Delay if paginating while scrolled.
*
* TODO why does the FlatList jump without this delay?
*
* Tbh it feels a little more natural with a slight delay.
*/
if (this.pastMessages.size > 0) {
await new Promise(y => setTimeout(y, 500))
}
const response = await this.agent.api.chat.bsky.convo.getMessages(
{
cursor: this.historyCursor,
convoId: this.convoId,
limit: isNative ? 25 : 50,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {cursor, messages} = response.data
this.historyCursor = cursor || null
for (const message of messages) {
if (
ChatBskyConvoDefs.isMessageView(message) ||
ChatBskyConvoDefs.isDeletedMessageView(message)
) {
this.pastMessages.set(message.id, message)
// set to latest rev
if (
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
) {
this.eventsCursor = message.rev
}
}
}
this.isFetchingHistory = false
this.commit()
}
async ingestLatestEvents() {
if (this.status === ConvoStatus.Destroyed) return
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs} = response.data
for (const log of logs) {
/*
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (typeof log.rev === 'string') {
/*
* We only care about new events
*/
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
/*
* Update rev regardless of if it's a log type we care about or not
*/
this.eventsCursor = log.rev
/*
* This is VERY important. We don't want to insert any messages from
* your other chats.
*/
if (log.convoId !== this.convoId) continue
if (
ChatBskyConvoDefs.isLogCreateMessage(log) &&
ChatBskyConvoDefs.isMessageView(log.message)
) {
if (this.newMessages.has(log.message.id)) {
// Trust the log as the source of truth on ordering
this.newMessages.delete(log.message.id)
}
this.newMessages.set(log.message.id, log.message)
} else if (
ChatBskyConvoDefs.isLogDeleteMessage(log) &&
ChatBskyConvoDefs.isDeletedMessageView(log.message)
) {
/*
* Update if we have this in state. If we don't, don't worry about it.
*/
if (this.pastMessages.has(log.message.id)) {
/*
* For now, we remove deleted messages from the thread, if we receive one.
*
* To support them, it'd look something like this:
* this.pastMessages.set(log.message.id, log.message)
*/
this.pastMessages.delete(log.message.id)
}
}
}
}
}
this.commit()
}
async processPendingMessages() {
const pendingMessage = Array.from(this.pendingMessages.values()).shift()
/*
* If there are no pending messages, we're done.
*/
if (!pendingMessage) {
this.isProcessingPendingMessages = false
return
}
try {
this.isProcessingPendingMessages = true
// throw new Error('UNCOMMENT TO TEST RETRY')
const {id, message} = pendingMessage
const response = await this.agent.api.chat.bsky.convo.sendMessage(
{
convoId: this.convoId,
message,
},
{
encoding: 'application/json',
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const res = response.data
/*
* Insert into `newMessages` as soon as we have a real ID. That way, when
* we get an event log back, we can replace in situ.
*/
this.newMessages.set(res.id, {
...res,
$type: 'chat.bsky.convo.defs#messageView',
sender: this.sender,
})
this.pendingMessages.delete(id)
await this.processPendingMessages()
this.commit()
} catch (e) {
this.footerItems.set('pending-retry', {
type: 'pending-retry',
key: 'pending-retry',
retry: this.batchRetryPendingMessages.bind(this),
})
this.commit()
}
}
async batchRetryPendingMessages() {
this.footerItems.delete('pending-retry')
this.commit()
try {
const messageArray = Array.from(this.pendingMessages.values())
const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
{
items: messageArray.map(({message}) => ({
convoId: this.convoId,
message,
})),
},
{
encoding: 'application/json',
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {items} = data
/*
* Insert into `newMessages` as soon as we have a real ID. That way, when
* we get an event log back, we can replace in situ.
*/
for (const item of items) {
this.newMessages.set(item.id, {
...item,
$type: 'chat.bsky.convo.defs#messageView',
sender: this.convo?.members.find(
m => m.did === this.__tempFromUserDid,
),
})
}
for (const pendingMessage of messageArray) {
this.pendingMessages.delete(pendingMessage.id)
}
this.commit()
} catch (e) {
this.footerItems.set('pending-retry', {
type: 'pending-retry',
key: 'pending-retry',
retry: this.batchRetryPendingMessages.bind(this),
})
this.commit()
}
}
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
if (this.status === ConvoStatus.Destroyed) return
// Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim()) return
const tempId = nanoid()
this.pendingMessages.set(tempId, {
id: tempId,
message,
})
this.commit()
if (!this.isProcessingPendingMessages) {
this.processPendingMessages()
}
}
/*
* Items in reverse order, since FlatList inverts
*/
get items(): ConvoItem[] {
const items: ConvoItem[] = []
// `newMessages` is in insertion order, unshift to reverse
this.newMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.unshift({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.unshift({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
// `newMessages` is in insertion order, unshift to reverse
this.pendingMessages.forEach(m => {
items.unshift({
type: 'pending-message',
key: m.id,
message: {
...m.message,
id: nanoid(),
rev: '__fake__',
sentAt: new Date().toISOString(),
sender: this.sender,
},
nextMessage: null,
})
})
this.footerItems.forEach(item => {
items.unshift(item)
})
this.pastMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.push({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.push({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
return items.map((item, i) => {
let nextMessage = null
const isMessage = isConvoItemMessage(item)
if (isMessage) {
if (
isMessage &&
(ChatBskyConvoDefs.isMessageView(item.message) ||
ChatBskyConvoDefs.isDeletedMessageView(item.message))
) {
const next = items[i - 1]
if (
isConvoItemMessage(next) &&
next &&
(ChatBskyConvoDefs.isMessageView(next.message) ||
ChatBskyConvoDefs.isDeletedMessageView(next.message))
) {
nextMessage = next.message
}
}
return {
...item,
nextMessage,
}
}
return item
})
}
destroy() {
this.status = ConvoStatus.Destroyed
this.commit()
}
get state(): ConvoState {
switch (this.status) {
case ConvoStatus.Initializing: {
return {
status: ConvoStatus.Initializing,
}
}
case ConvoStatus.Ready: {
return {
status: ConvoStatus.Ready,
items: this.items,
convo: this.convo!,
isFetchingHistory: this.isFetchingHistory,
}
}
case ConvoStatus.Error: {
return {
status: ConvoStatus.Error,
error: this.error,
}
}
case ConvoStatus.Destroyed: {
return {
status: ConvoStatus.Destroyed,
}
}
default: {
return {
status: ConvoStatus.Uninitialized,
}
}
}
}
private _emitter = new EventEmitter()
private commit() {
this._emitter.emit('update')
}
on(event: 'update', cb: () => void) {
this._emitter.on(event, cb)
}
off(event: 'update', cb: () => void) {
this._emitter.off(event, cb)
}
}
-54
View File
@@ -1,54 +0,0 @@
import React, {useContext, useEffect, useMemo, useState} from 'react'
import {BskyAgent} from '@atproto-labs/api'
import {Convo, ConvoParams} from '#/state/messages/convo'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<{
service: Convo
state: Convo['state']
} | null>(null)
export function useChat() {
const ctx = useContext(ChatContext)
if (!ctx) {
throw new Error('useChat must be used within a ChatProvider')
}
return ctx
}
export function ChatProvider({
children,
convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [service] = useState(
() =>
new Convo({
convoId,
agent: new BskyAgent({
service: serviceUrl,
}),
__tempFromUserDid: getAgent().session?.did!,
}),
)
const [state, setState] = useState(service.state)
useEffect(() => {
service.initialize()
}, [service])
useEffect(() => {
const update = () => setState(service.state)
service.on('update', update)
return () => {
service.destroy()
}
}, [service])
const value = useMemo(() => ({service, state}), [service, state])
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
}
+4 -4
View File
@@ -1,11 +1,11 @@
import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {migrate} from '#/state/persisted/legacy'
import {defaults, Schema} from '#/state/persisted/schema'
import {migrate} from '#/state/persisted/legacy'
import * as store from '#/state/persisted/store'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
import BroadcastChannel from '#/lib/broadcast'
export type {Schema, PersistedAccount} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
-2
View File
@@ -80,7 +80,6 @@ export const schema = z.object({
pdsAddressHistory: z.array(z.string()).optional(),
disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>
@@ -118,5 +117,4 @@ export const defaults: Schema = {
pdsAddressHistory: [],
disableHaptics: false,
disableAutoplay: prefersReducedMotion,
kawaii: false,
}
+1 -4
View File
@@ -7,7 +7,6 @@ import {Provider as DisableHapticsProvider} from './disable-haptics'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as HiddenPostsProvider} from './hidden-posts'
import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as KawaiiProvider} from './kawaii'
import {Provider as LanguagesProvider} from './languages'
export {
@@ -33,9 +32,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<InAppBrowserProvider>
<DisableHapticsProvider>
<AutoplayProvider>
<DmServiceUrlProvider>
<KawaiiProvider>{children}</KawaiiProvider>
</DmServiceUrlProvider>
<DmServiceUrlProvider>{children}</DmServiceUrlProvider>
</AutoplayProvider>
</DisableHapticsProvider>
</InAppBrowserProvider>
-50
View File
@@ -1,50 +0,0 @@
import React from 'react'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['kawaii']
const stateContext = React.createContext<StateContext>(
persisted.defaults.kawaii,
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('kawaii'))
const setStateWrapped = React.useCallback(
(kawaii: persisted.Schema['kawaii']) => {
setState(kawaii)
persisted.write('kawaii', kawaii)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('kawaii'))
})
}, [setStateWrapped])
React.useEffect(() => {
// dumb and stupid but it's web only so just refresh the page if you want to change it
if (isWeb) {
const kawaii = new URLSearchParams(window.location.search).get('kawaii')
switch (kawaii) {
case 'true':
setStateWrapped(true)
break
case 'false':
setStateWrapped(false)
break
}
}
}, [setStateWrapped])
return <stateContext.Provider value={state}>{children}</stateContext.Provider>
}
export function useKawaiiMode() {
return React.useContext(stateContext)
}
+5 -3
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {AppBskyLabelerDefs, InterpretedLabelValueDefinition} from '@atproto/api'
import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
import {useLabelDefinitionsQuery} from '../queries/preferences'
interface StateContext {
@@ -14,7 +13,10 @@ const stateContext = React.createContext<StateContext>({
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const state = useLabelDefinitionsQuery()
const {labelDefs, labelers} = useLabelDefinitionsQuery()
const state = {labelDefs, labelers}
return <stateContext.Provider value={state}>{children}</stateContext.Provider>
}
-61
View File
@@ -1,61 +0,0 @@
import React, {createContext, useContext, useMemo} from 'react'
import {BSKY_LABELER_DID, ModerationOpts} from '@atproto/api'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {useSession} from '#/state/session'
import {usePreferencesQuery} from '../queries/preferences'
export const moderationOptsContext = createContext<ModerationOpts | undefined>(
undefined,
)
// used in the moderation state devtool
export const moderationOptsOverrideContext = createContext<
ModerationOpts | undefined
>(undefined)
export function useModerationOpts() {
return useContext(moderationOptsContext)
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const override = useContext(moderationOptsOverrideContext)
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
const {labelDefs} = useLabelDefinitions()
const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
const userDid = currentAccount?.did
const moderationPrefs = prefs.data?.moderationPrefs
const value = useMemo<ModerationOpts | undefined>(() => {
if (override) {
return override
}
if (!moderationPrefs) {
return undefined
}
return {
userDid,
prefs: {
...moderationPrefs,
labelers: moderationPrefs.labelers.length
? moderationPrefs.labelers
: [
{
did: BSKY_LABELER_DID,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
},
],
hiddenPosts: hiddenPosts || [],
},
labelDefs,
}
}, [override, userDid, labelDefs, moderationPrefs, hiddenPosts])
return (
<moderationOptsContext.Provider value={value}>
{children}
</moderationOptsContext.Provider>
)
}
+1 -2
View File
@@ -6,8 +6,7 @@ import {isJustAMute} from '#/lib/moderation'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences'
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
const DEFAULT_MOD_OPTS = {
userDid: undefined,
@@ -1,25 +0,0 @@
import {BskyAgent} from '@atproto-labs/api'
import {useQuery} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery(convoId: string) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: RQKEY(convoId),
queryFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.getConvo(
{convoId},
{headers},
)
return data.convo
},
})
}
@@ -1,39 +0,0 @@
import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {useHeaders} from './temp-headers'
export function useGetConvoForMembers({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async (members: string[]) => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.getConvoForMembers(
{members: members},
{headers},
)
return data
},
onSuccess: data => {
queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -1,68 +0,0 @@
import {
BskyAgent,
ChatBskyConvoLeaveConvo,
ChatBskyConvoListConvos,
} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
import {useHeaders} from './temp-headers'
export function useLeaveConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.leaveConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onMutate: () => {
queryClient.setQueryData(
CONVO_LIST_KEY,
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
console.log('old', old)
if (!old) return old
return {
...old,
pages: old.pages.map(page => {
return {
...page,
convos: page.convos.filter(convo => convo.id !== convoId),
}
}),
}
},
)
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
onError?.(error)
},
})
}
@@ -1,28 +0,0 @@
import {BskyAgent} from '@atproto-labs/api'
import {useInfiniteQuery} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvos() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useInfiniteQuery({
queryKey: RQKEY,
queryFn: async ({pageParam}) => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.listConvos(
{cursor: pageParam},
{headers},
)
return data
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
})
}
@@ -1,84 +0,0 @@
import {
BskyAgent,
ChatBskyConvoMuteConvo,
ChatBskyConvoUnmuteConvo,
} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
import {useHeaders} from './temp-headers'
export function useMuteConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.muteConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
export function useUnmuteConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -1,11 +0,0 @@
import {useSession} from '#/state/session'
// toy auth
export const useHeaders = () => {
const {currentAccount} = useSession()
return {
get Authorization() {
return currentAccount!.did
},
}
}
+1 -1
View File
@@ -28,8 +28,8 @@ import {
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {useModerationOpts} from '../preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
import {FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread'
+1 -1
View File
@@ -13,7 +13,7 @@ import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {useModerationOpts} from '../preferences'
import {truncateAndInvalidate} from '../util'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {CachedFeedPage, FeedPage} from './types'
+1 -2
View File
@@ -32,8 +32,7 @@ import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
import {usePreferencesQuery} from './preferences'
import {useModerationOpts, usePreferencesQuery} from './preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
type ActorDid = string
+45 -3
View File
@@ -1,24 +1,28 @@
import {createContext, useContext, useMemo} from 'react'
import {
AppBskyActorDefs,
BSKY_LABELER_DID,
BskyFeedViewPreference,
LabelPreference,
ModerationOpts,
} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics'
import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {STALE} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
DEFAULT_THREAD_VIEW_PREFS,
} from '#/state/queries/preferences/const'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {
ThreadViewPreferences,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
import {useAgent} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {saveLabelers} from '#/state/session/agent-config'
export * from '#/state/queries/preferences/const'
@@ -32,7 +36,7 @@ export function usePreferencesQuery() {
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: replaceEqualDeep,
structuralSharing: true,
refetchOnWindowFocus: true,
queryKey: preferencesQueryKey,
queryFn: async () => {
@@ -75,6 +79,44 @@ export function usePreferencesQuery() {
})
}
// used in the moderation state devtool
export const moderationOptsOverrideContext = createContext<
ModerationOpts | undefined
>(undefined)
export function useModerationOpts() {
const override = useContext(moderationOptsOverrideContext)
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
const {labelDefs} = useLabelDefinitions()
const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
const opts = useMemo<ModerationOpts | undefined>(() => {
if (override) {
return override
}
if (!prefs.data) {
return
}
return {
userDid: currentAccount?.did,
prefs: {
...prefs.data.moderationPrefs,
labelers: prefs.data.moderationPrefs.labelers.length
? prefs.data.moderationPrefs.labelers
: [
{
did: BSKY_LABELER_DID,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
},
],
hiddenPosts: hiddenPosts || [],
},
labelDefs,
}
}, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
return opts
}
export function useClearPreferencesMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
+4 -2
View File
@@ -18,9 +18,11 @@ import {
} from '#/lib/api/feed/utils'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {
useModerationOpts,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts'
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
+303 -347
View File
@@ -1,17 +1,18 @@
import React from 'react'
import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {logEvent, tryFetchGates} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events'
import {
SessionAccount,
SessionApiContext,
SessionStateContext,
} from '#/state/session/types'
import {
agentToSessionAccount,
configureModerationForAccount,
@@ -20,21 +21,26 @@ import {
createAgentAndLogin,
isSessionDeactivated,
isSessionExpired,
} from './util'
export type {SessionAccount} from '#/state/session/types'
import {
SessionAccount,
SessionApiContext,
SessionStateContext,
} from '#/state/session/types'
sessionAccountToAgentSession,
} from '#/state/session/util'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import * as Toast from '#/view/com/util/Toast'
import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events'
export type {CurrentAccount, SessionAccount} from '#/state/session/types'
export {isSessionDeactivated}
const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
/**
* Only used for the initial agent values in state and context. Replaced
* immediately, and should not be reused.
*/
const INITIAL_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
configureModerationForGuest()
const StateContext = React.createContext<SessionStateContext>({
currentAgent: INITIAL_AGENT,
isInitialLoad: true,
isSwitchingAccounts: false,
accounts: [],
@@ -50,127 +56,134 @@ const ApiContext = React.createContext<SessionApiContext>({
resumeSession: async () => {},
removeAccount: () => {},
selectAccount: async () => {},
updateCurrentAccount: () => {},
refreshSession: () => {},
clearCurrentAccount: () => {},
updateCurrentAccount: async () => {},
})
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
function __getAgent() {
return __globalAgent
}
type State = {
accounts: SessionStateContext['accounts']
currentAccountDid: string | undefined
needsPersist: boolean
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const isDirty = React.useRef(false)
const [currentAgent, setCurrentAgent] =
React.useState<BskyAgent>(INITIAL_AGENT)
const [accounts, setAccounts] = React.useState<SessionAccount[]>(
persisted.get('session').accounts,
)
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
const [state, setState] = React.useState<State>({
accounts: persisted.get('session').accounts,
currentAccountDid: undefined, // assume logged out to start
needsPersist: false,
})
const currentAccountDid = React.useMemo(
() => currentAgent.session?.did,
[currentAgent],
)
const currentAccount = React.useMemo(
() => accounts.find(a => a.did === currentAccountDid),
[accounts, currentAccountDid],
)
const upsertAccount = React.useCallback(
(account: SessionAccount, expired = false) => {
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAccountDid: expired ? undefined : account.did,
needsPersist: true,
}
})
const persistNextUpdate = React.useCallback(
() => (isDirty.current = true),
[],
)
const upsertAndPersistAccount = React.useCallback(
(account: SessionAccount) => {
persistNextUpdate()
setAccounts(accounts => [
account,
...accounts.filter(a => a.did !== account.did),
])
},
[setState],
[setAccounts, persistNextUpdate],
)
const clearCurrentAccount = React.useCallback(() => {
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
// immediate clear this so any pending requests don't use it
currentAgent.setPersistSessionHandler(() => {})
persistNextUpdate()
const newAgent = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAccountDid: undefined,
needsPersist: true,
}))
}, [setState])
setCurrentAgent(newAgent)
}, [currentAgent, persistNextUpdate, setCurrentAgent])
const createPersistSessionHandler = React.useCallback(
(
agent: BskyAgent,
account: SessionAccount,
persistSessionCallback: (props: {
expired: boolean
refreshedAccount: SessionAccount
}) => void,
{
networkErrorCallback,
}: {
networkErrorCallback?: () => void
} = {},
): AtpPersistSessionHandler => {
return function persistSession(event, session) {
const expired = event === 'expired' || event === 'create-failed'
React.useEffect(() => {
/*
* This method is continually overwritten when `currentAgent` and dependent
* methods local to this file change, so that the freshest agent and
* handlers are always used.
*/
currentAgent.setPersistSessionHandler(event => {
logger.debug(
`session: persistSession`,
{event},
logger.DebugContext.session,
)
if (event === 'network-error') {
logger.warn(
`session: persistSessionHandler received network-error event`,
)
networkErrorCallback?.()
return
}
const expired = event === 'expired' || event === 'create-failed'
// TODO: use agentToSessionAccount for this too.
const refreshedAccount: SessionAccount = {
service: account.service,
did: session?.did || account.did,
handle: session?.handle || account.handle,
email: session?.email || account.email,
emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
emailAuthFactor: session?.emailAuthFactor || account.emailAuthFactor,
deactivated: isSessionDeactivated(session?.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
/*
* Tokens are undefined if the session expires, or if creation fails for
* any reason e.g. tokens are invalid, network error, etc.
*/
refreshJwt: session?.refreshJwt,
accessJwt: session?.accessJwt,
}
logger.debug(`session: persistSession`, {
event,
deactivated: refreshedAccount.deactivated,
})
if (expired) {
logger.warn(`session: expired`)
emitSessionDropped()
}
/*
* If the session expired, or it was successfully created/updated, we want
* to update/persist the data.
*
* If the session creation failed, it could be a network error, or it could
* be more serious like an invalid token(s). We can't differentiate, so in
* order to allow the user to get a fresh token (if they need it), we need
* to persist this data and wipe their tokens, effectively logging them
* out.
*/
persistSessionCallback({
expired,
refreshedAccount,
})
/*
* Special case for a network error that occurs when calling
* `resumeSession`, which happens on page load, when switching
* accounts, or when refreshing user session data.
*
* When this occurs, we drop the user back out to the login screen, but
* we don't clear tokens, allowing them to quickly log back in when their
* connection improves.
*/
if (event === 'network-error') {
logger.warn(
`session: persistSessionHandler received network-error event`,
)
emitSessionDropped()
clearCurrentAccount()
setTimeout(() => {
Toast.show(`Your internet connection is unstable. Please try again.`)
}, 100)
return
}
},
[],
)
/*
* If the session was expired naturally, we want to drop the user back
* out to log in.
*/
if (expired) {
logger.warn(`session: expired`)
emitSessionDropped()
clearCurrentAccount()
setTimeout(() => {
Toast.show(`Sorry! We need you to enter your password.`)
}, 100)
}
/**
* The updated account object, derived from the updated session we just
* received from this callback.
*/
const refreshedAccount = agentToSessionAccount(currentAgent)
if (refreshedAccount) {
/*
* If the session expired naturally, or it was otherwise successfully
* created/updated, we want to update/persist the data.
*/
upsertAndPersistAccount(refreshedAccount)
} else {
/*
* This should never happen based on current `AtpAgent` handling, but
* it's here for TypeScript, and should result in the same handling as
* a session expiration.
*/
logger.error(`session: persistSession failed to get refreshed account`)
emitSessionDropped()
clearCurrentAccount()
setTimeout(() => {
Toast.show(`Sorry! We need you to enter your password.`)
}, 100)
}
})
}, [currentAgent, clearCurrentAccount, upsertAndPersistAccount])
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
async ({
@@ -181,10 +194,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
inviteCode,
verificationPhone,
verificationCode,
}) => {
}: any) => {
logger.info(`session: creating account`)
track('Try Create Account')
logEvent('account:create:begin', {})
const {agent, account, fetchingGates} = await createAgentAndCreateAccount(
{
service,
@@ -197,31 +211,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
)
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
__globalAgent = agent
await fetchingGates
upsertAccount(account)
setCurrentAgent(agent)
upsertAndPersistAccount(account)
logger.debug(`session: created account`, {}, logger.DebugContext.session)
track('Create Account')
logEvent('account:create:success', {})
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[upsertAndPersistAccount],
)
const login = React.useCallback<SessionApiContext['login']>(
async ({service, identifier, password, authFactorToken}, logContext) => {
logger.debug(`session: login`, {}, logger.DebugContext.session)
const {agent, account, fetchingGates} = await createAgentAndLogin({
service,
identifier,
@@ -229,164 +233,92 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
authFactorToken,
})
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
__globalAgent = agent
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await fetchingGates
upsertAccount(account)
setCurrentAgent(agent)
upsertAndPersistAccount(account)
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
track('Sign In', {resumedSession: false})
logEvent('account:loggedIn', {logContext, withPassword: true})
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[upsertAndPersistAccount],
)
const logout = React.useCallback<SessionApiContext['logout']>(
async logContext => {
logger.debug(`session: logout`)
clearCurrentAccount()
setState(s => {
return {
accounts: s.accounts.map(a => ({
...a,
refreshJwt: undefined,
accessJwt: undefined,
})),
currentAccountDid: s.currentAccountDid,
needsPersist: true,
}
})
persistNextUpdate()
setAccounts(accounts =>
accounts.map(a => ({
...a,
accessJwt: undefined,
refreshJwt: undefined,
})),
)
logEvent('account:loggedOut', {logContext})
},
[clearCurrentAccount, setState],
[clearCurrentAccount, persistNextUpdate, setAccounts],
)
const initSession = React.useCallback<SessionApiContext['initSession']>(
async account => {
logger.debug(`session: initSession`, {}, logger.DebugContext.session)
const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
const agent = new BskyAgent({service: account.service})
const newAgent = new BskyAgent({
service: account.service,
})
// restore the correct PDS URL if available
if (account.pdsUrl) {
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
newAgent.pdsUrl = newAgent.api.xrpc.uri = new URL(account.pdsUrl)
}
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await configureModerationForAccount(agent, account)
const accountOrSessionDeactivated =
isSessionDeactivated(account.accessJwt) || account.deactivated
const prevSession = {
...account,
accessJwt: account.accessJwt || '',
refreshJwt: account.refreshJwt || '',
did: account.did,
handle: account.handle,
}
/**
* Optimistically update moderation services so that when the new agent
* is applied, they're ready.
*
* If session resumption fails, this will be reset by
* `clearCurrentAccount`.
*/
await configureModerationForAccount(newAgent, account)
if (isSessionExpired(account)) {
logger.debug(`session: attempting to resume using previous session`)
try {
const freshAccount = await resumeSessionWithFreshAccount()
__globalAgent = agent
await fetchingGates
upsertAccount(freshAccount)
} catch (e) {
/*
* Note: `agent.persistSession` is also called when this fails, and
* we handle that failure via `createPersistSessionHandler`
*/
logger.info(`session: resumeSessionWithFreshAccount failed`, {
message: e,
})
__globalAgent = PUBLIC_BSKY_AGENT
// TODO: Should this update currentAccountDid?
}
} else {
logger.debug(`session: attempting to reuse previous session`)
agent.session = prevSession
__globalAgent = agent
await fetchingGates
upsertAccount(account)
if (accountOrSessionDeactivated) {
// don't attempt to resume
// use will be taken to the deactivated screen
logger.debug(`session: reusing session for deactivated account`)
return
}
// Intentionally not awaited to unblock the UI:
resumeSessionWithFreshAccount()
.then(freshAccount => {
if (JSON.stringify(account) !== JSON.stringify(freshAccount)) {
logger.info(
`session: reuse of previous session returned a fresh account, upserting`,
)
upsertAccount(freshAccount)
}
})
.catch(e => {
/*
* Note: `agent.persistSession` is also called when this fails, and
* we handle that failure via `createPersistSessionHandler`
*/
logger.info(`session: resumeSessionWithFreshAccount failed`, {
message: e,
})
__globalAgent = PUBLIC_BSKY_AGENT
// TODO: Should this update currentAccountDid?
})
}
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
logger.debug(`session: resumeSessionWithFreshAccount`)
await networkRetry(1, () => agent.resumeSession(prevSession))
const sessionAccount = agentToSessionAccount(agent)
/*
* If `agent.resumeSession` fails above, it'll throw. This is just to
* make TypeScript happy.
* If session is expired, attempt to refresh the session using the
* refresh token via `resumeSession`
*/
if (!sessionAccount) {
throw new Error(`session: initSession failed to establish a session`)
}
return sessionAccount
logger.debug(
`session: attempting to resumeSession using previous session`,
{},
logger.DebugContext.session,
)
await networkRetry(1, () => newAgent.resumeSession(prevSession))
setCurrentAgent(newAgent)
upsertAndPersistAccount(agentToSessionAccount(newAgent)!)
} else {
/*
* If the session is not expired, assume we can reuse it.
*/
logger.debug(
`session: attempting to reuse previous session`,
{},
logger.DebugContext.session,
)
newAgent.session = prevSession
setCurrentAgent(newAgent)
upsertAndPersistAccount(account)
}
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[upsertAndPersistAccount],
)
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
@@ -401,59 +333,45 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
setIsInitialLoad(false)
}
},
[initSession],
[initSession, setIsInitialLoad],
)
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
account => {
setState(s => {
return {
accounts: s.accounts.filter(a => a.did !== account.did),
currentAccountDid: s.currentAccountDid,
needsPersist: true,
}
})
persistNextUpdate()
setAccounts(accounts => accounts.filter(a => a.did !== account.did))
},
[setState],
[setAccounts, persistNextUpdate],
)
const updateCurrentAccount = React.useCallback<
SessionApiContext['updateCurrentAccount']
>(
account => {
setState(s => {
const currentAccount = s.accounts.find(
a => a.did === s.currentAccountDid,
)
// ignore, should never happen
if (!currentAccount) return s
const refreshSession = React.useCallback<
SessionApiContext['refreshSession']
>(async () => {
const {accounts: persistedAccounts} = persisted.get('session')
const selectedAccount = persistedAccounts.find(
a => a.did === currentAccountDid,
)
if (!selectedAccount) return
const updatedAccount = {
...currentAccount,
handle: account.handle || currentAccount.handle,
email: account.email || currentAccount.email,
emailConfirmed:
account.emailConfirmed !== undefined
? account.emailConfirmed
: currentAccount.emailConfirmed,
emailAuthFactor:
account.emailAuthFactor !== undefined
? account.emailAuthFactor
: currentAccount.emailAuthFactor,
}
// update and swap agent to trigger render refresh
const newAgent = currentAgent.clone()
await newAgent.resumeSession(sessionAccountToAgentSession(selectedAccount)!)
const refreshedAccount = agentToSessionAccount(newAgent)
await configureModerationForAccount(newAgent, refreshedAccount!)
persistNextUpdate()
upsertAndPersistAccount(refreshedAccount!)
setCurrentAgent(newAgent)
}, [
currentAccountDid,
currentAgent,
setCurrentAgent,
persistNextUpdate,
upsertAndPersistAccount,
])
return {
accounts: [
updatedAccount,
...s.accounts.filter(a => a.did !== currentAccount.did),
],
currentAccountDid: s.currentAccountDid,
needsPersist: true,
}
})
},
[setState],
)
const updateCurrentAccount = React.useCallback(async () => {
await refreshSession()
}, [refreshSession])
const selectAccount = React.useCallback<SessionApiContext['selectAccount']>(
async (account, logContext) => {
@@ -469,55 +387,72 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
throw e
}
},
[initSession],
[setIsSwitchingAccounts, initSession],
)
React.useEffect(() => {
if (state.needsPersist) {
state.needsPersist = false
if (isDirty.current) {
isDirty.current = false
persisted.write('session', {
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAccountDid,
),
accounts,
currentAccount,
})
}
}, [state])
}, [accounts, currentAccount])
React.useEffect(() => {
return persisted.onUpdate(() => {
return persisted.onUpdate(async () => {
const persistedSession = persisted.get('session')
logger.debug(`session: persisted onUpdate`, {})
logger.debug(
`session: persisted onUpdate`,
{},
logger.DebugContext.session,
)
/*
* Accounts are already persisted on other side of broadcast, but we need
* to update them in memory in this tab.
*/
setAccounts(persistedSession.accounts)
const selectedAccount = persistedSession.accounts.find(
a => a.did === persistedSession.currentAccount?.did,
)
if (selectedAccount && selectedAccount.refreshJwt) {
if (selectedAccount.did !== state.currentAccountDid) {
logger.debug(`session: persisted onUpdate, switching accounts`, {
from: {
did: state.currentAccountDid,
if (selectedAccount?.did !== currentAccountDid) {
logger.debug(
`session: persisted onUpdate, switching accounts`,
{
from: {
did: currentAccountDid,
},
to: {
did: selectedAccount.did,
},
},
to: {
did: selectedAccount.did,
},
})
logger.DebugContext.session,
)
initSession(selectedAccount)
await initSession(selectedAccount)
} else {
logger.debug(`session: persisted onUpdate, updating session`, {})
logger.debug(
`session: persisted onUpdate, updating session`,
{},
logger.DebugContext.session,
)
/*
* Use updated session in this tab's agent. Do not call
* upsertAccount, since that will only persist the session that's
* already persisted, and we'll get a loop between tabs.
* Create a new agent for the same account, with updated data from
* other side of broadcast. Update on state to re-derive
* `currentAccount` and re-render the app.
*/
// @ts-ignore we checked for `refreshJwt` above
__globalAgent.session = selectedAccount
const newAgent = currentAgent.clone()
newAgent.session = sessionAccountToAgentSession(selectedAccount)
await configureModerationForAccount(newAgent, selectedAccount)
setCurrentAgent(newAgent)
}
} else if (!selectedAccount && state.currentAccountDid) {
} else if (!selectedAccount && currentAccountDid) {
logger.debug(
`session: persisted onUpdate, logging out`,
{},
@@ -532,26 +467,32 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
*/
clearCurrentAccount()
}
setState(() => ({
accounts: persistedSession.accounts,
currentAccountDid: selectedAccount?.did,
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}))
})
}, [state, setState, clearCurrentAccount, initSession])
}, [
currentAccountDid,
setAccounts,
clearCurrentAccount,
initSession,
currentAgent,
setCurrentAgent,
])
const stateContext = React.useMemo(
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAccountDid,
),
currentAgent,
isInitialLoad,
isSwitchingAccounts,
hasSession: !!state.currentAccountDid,
currentAccount,
accounts,
hasSession: Boolean(currentAccount),
}),
[state, isInitialLoad, isSwitchingAccounts],
[
currentAgent,
isInitialLoad,
isSwitchingAccounts,
accounts,
currentAccount,
],
)
const api = React.useMemo(
@@ -563,8 +504,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
resumeSession,
removeAccount,
selectAccount,
updateCurrentAccount,
refreshSession,
clearCurrentAccount,
updateCurrentAccount,
}),
[
createAccount,
@@ -574,11 +516,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
resumeSession,
removeAccount,
selectAccount,
updateCurrentAccount,
refreshSession,
clearCurrentAccount,
updateCurrentAccount,
],
)
if (IS_DEV && isWeb) {
// @ts-ignore
window.agent = currentAgent
}
return (
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>{children}</ApiContext.Provider>
@@ -596,8 +544,8 @@ export function useSessionApi() {
export function useRequireAuth() {
const {hasSession} = useSession()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAll = useCloseAllActiveElements()
const {signinDialogControl} = useGlobalDialogsControlContext()
return React.useCallback(
(fn: () => void) => {
@@ -605,13 +553,21 @@ export function useRequireAuth() {
fn()
} else {
closeAll()
signinDialogControl.open()
setShowLoggedOut(true)
}
},
[hasSession, signinDialogControl, closeAll],
[hasSession, setShowLoggedOut, closeAll],
)
}
export function useAgent() {
return React.useMemo(() => ({getAgent: __getAgent}), [])
const {currentAgent} = useSession()
return React.useMemo(
() => ({
getAgent() {
return currentAgent
},
}),
[currentAgent],
)
}
+31 -2
View File
@@ -1,15 +1,37 @@
import {BskyAgent} from '@atproto/api'
import {LogEvents} from '#/lib/statsig/statsig'
import {PersistedAccount} from '#/state/persisted'
/**
* Alias for `PersistedAccount` from persisted storage.
*/
export type SessionAccount = PersistedAccount
/**
* Subset of `SessionAccount` that excludes tokens.
*/
export type CurrentAccount = Omit<SessionAccount, 'accessJwt' | 'refreshJwt'>
/**
* Context shape returned from `useSession()`
*/
export type SessionStateContext = {
accounts: SessionAccount[]
currentAccount: SessionAccount | undefined
currentAgent: BskyAgent
isInitialLoad: boolean
isSwitchingAccounts: boolean
hasSession: boolean
accounts: SessionAccount[]
/**
* Contains the full account object persisted to storage, minus access
* tokens.
*/
currentAccount: CurrentAccount | undefined
}
/**
* Context shape returned from `useSessionApi()`
*/
export type SessionApiContext = {
createAccount: (props: {
service: string
@@ -52,6 +74,13 @@ export type SessionApiContext = {
account: SessionAccount,
logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void>
/**
* Refreshes the BskyAgent's session and derive a fresh `currentAccount`
*/
refreshSession: () => void
/**
* @deprecated Use `refreshSession` instead.
*/
updateCurrentAccount: (
account: Partial<
Pick<
+14
View File
@@ -43,6 +43,20 @@ export function agentToSessionAccount(
}
}
export function sessionAccountToAgentSession(
account: SessionAccount,
): BskyAgent['session'] {
return {
refreshJwt: account.refreshJwt || '',
accessJwt: account.accessJwt || '',
did: account.did,
handle: account.handle,
email: account.email,
emailConfirmed: account.emailConfirmed,
emailAuthFactor: account.emailAuthFactor,
}
}
export function configureModerationForGuest() {
switchToBskyAppLabeler()
}
+195
View File
@@ -0,0 +1,195 @@
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyRichtextFacet,
} from '@atproto/api'
import {ValidationResult} from '@atproto/lexicon'
export interface Message {
id?: string
text: string
/** Annotations of text (mentions, URLs, hashtags, etc) */
facets?: AppBskyRichtextFacet.Main[]
embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
[k: string]: unknown
}
export function isMessage(v: unknown): v is Message {
return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message'
}
export function validateMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface MessageView {
id: string
rev: string
text: string
/** Annotations of text (mentions, URLs, hashtags, etc) */
facets?: AppBskyRichtextFacet.Main[]
embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
sender?: MessageViewSender
sentAt: string
[k: string]: unknown
}
export function isMessageView(v: unknown): v is MessageView {
return (
isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView'
)
}
export function validateMessageView(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface DeletedMessage {
id: string
rev?: string
sender?: MessageViewSender
sentAt: string
[k: string]: unknown
}
export function isDeletedMessage(v: unknown): v is DeletedMessage {
return (
isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage'
)
}
export function validateDeletedMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface MessageViewSender {
did: string
[k: string]: unknown
}
export function isMessageViewSender(v: unknown): v is MessageViewSender {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.defs#messageViewSender'
)
}
export function validateMessageViewSender(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface ChatView {
id: string
rev: string
members: AppBskyActorDefs.ProfileViewBasic[]
lastMessage?:
| MessageView
| DeletedMessage
| {$type: string; [k: string]: unknown}
unreadCount: number
[k: string]: unknown
}
export function isChatView(v: unknown): v is ChatView {
return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView'
}
export function validateChatView(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export type IncomingMessageSetting =
| 'all'
| 'none'
| 'following'
| (string & {})
export interface LogBeginChat {
rev: string
chatId: string
[k: string]: unknown
}
export function isLogBeginChat(v: unknown): v is LogBeginChat {
return (
isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat'
)
}
export function validateLogBeginChat(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface LogCreateMessage {
rev: string
chatId: string
message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
[k: string]: unknown
}
export function isLogCreateMessage(v: unknown): v is LogCreateMessage {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.defs#logCreateMessage'
)
}
export function validateLogCreateMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface LogDeleteMessage {
rev: string
chatId: string
message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
[k: string]: unknown
}
export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.defs#logDeleteMessage'
)
}
export function validateLogDeleteMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
export function hasProp<K extends PropertyKey>(
data: object,
prop: K,
): data is Record<K, unknown> {
return prop in data
}
+31
View File
@@ -0,0 +1,31 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
chatId: string
messageId: string
[k: string]: unknown
}
export type OutputSchema = TempDmDefs.DeletedMessage
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
chatId: string
}
export type InputSchema = undefined
export interface OutputSchema {
chat: TempDmDefs.ChatView
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
members: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
chat: TempDmDefs.ChatView
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+36
View File
@@ -0,0 +1,36 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
logs: (
| TempDmDefs.LogBeginChat
| TempDmDefs.LogCreateMessage
| TempDmDefs.LogDeleteMessage
| {$type: string; [k: string]: unknown}
)[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+37
View File
@@ -0,0 +1,37 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
chatId: string
limit?: number
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
messages: (
| TempDmDefs.MessageView
| TempDmDefs.DeletedMessage
| {$type: string; [k: string]: unknown}
)[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+28
View File
@@ -0,0 +1,28 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export type InputSchema = undefined
export interface OutputSchema {
allowIncoming: TempDmDefs.IncomingMessageSetting
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
export interface QueryParams {}
export interface InputSchema {
chatId: string
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+32
View File
@@ -0,0 +1,32 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
limit?: number
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
chats: TempDmDefs.ChatView[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
export interface QueryParams {}
export interface InputSchema {
chatId: string
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+31
View File
@@ -0,0 +1,31 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
chatId: string
message: TempDmDefs.Message
[k: string]: unknown
}
export type OutputSchema = TempDmDefs.MessageView
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+66
View File
@@ -0,0 +1,66 @@
import {ValidationResult} from '@atproto/lexicon'
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
items: BatchItem[]
[k: string]: unknown
}
export interface OutputSchema {
items: TempDmDefs.MessageView[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
export interface BatchItem {
chatId: string
message: TempDmDefs.Message
[k: string]: unknown
}
export function isBatchItem(v: unknown): v is BatchItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.sendMessageBatch#batchItem'
)
}
export function validateBatchItem(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
export function hasProp<K extends PropertyKey>(
data: object,
prop: K,
): data is Record<K, unknown> {
return prop in data
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
export interface QueryParams {}
export interface InputSchema {
chatId: string
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+31
View File
@@ -0,0 +1,31 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
chatId: string
messageId?: string
[k: string]: unknown
}
export type OutputSchema = TempDmDefs.ChatView
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+33
View File
@@ -0,0 +1,33 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
allowIncoming?: TempDmDefs.IncomingMessageSetting
[k: string]: unknown
}
export interface OutputSchema {
allowIncoming: TempDmDefs.IncomingMessageSetting
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+4 -9
View File
@@ -4,7 +4,6 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useKawaiiMode} from '#/state/preferences/kawaii'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
@@ -29,8 +28,6 @@ export const SplashScreen = ({
const t = useTheme()
const {isTabletOrMobile: isMobileWeb} = useWebMediaQueries()
const kawaii = useKawaiiMode()
return (
<>
{onDismiss && (
@@ -69,13 +66,11 @@ export const SplashScreen = ({
]}>
<ErrorBoundary>
<View style={[a.justify_center, a.align_center]}>
<Logo width={kawaii ? 300 : 92} fill="sky" />
<Logo width={92} fill="sky" />
{!kawaii && (
<View style={[a.pb_sm, a.pt_5xl]}>
<Logotype width={161} fill={t.atoms.text.color} />
</View>
)}
<View style={[a.pb_sm, a.pt_5xl]}>
<Logotype width={161} fill={t.atoms.text.color} />
</View>
<Text
style={[
+2 -12
View File
@@ -15,7 +15,6 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Logo} from '#/view/icons/Logo'
import {useKawaiiMode} from '../../../state/preferences/kawaii'
import {Link} from '../util/Link'
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
@@ -44,19 +43,10 @@ function HomeHeaderLayoutDesktopAndTablet({
const {hasSession} = useSession()
const {_} = useLingui()
const kawaii = useKawaiiMode()
return (
<>
{hasSession && (
<View
style={[
pal.view,
pal.border,
styles.bar,
styles.topBar,
kawaii && {paddingTop: 4, paddingBottom: 0},
]}>
<View style={[pal.view, pal.border, styles.bar, styles.topBar]}>
<Link
href="/settings/following-feed"
hitSlop={10}
@@ -68,7 +58,7 @@ function HomeHeaderLayoutDesktopAndTablet({
style={pal.textLight as FontAwesomeIconStyle}
/>
</Link>
<Logo width={kawaii ? 60 : 28} />
<Logo width={28} />
<Link
href="/settings/saved-feeds"
hitSlop={10}
+13 -14
View File
@@ -1,22 +1,21 @@
import React from 'react'
import {CenteredView} from '../util/Views'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {EmptyState} from '../util/EmptyState'
import {s} from 'lib/styles'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
import {s} from 'lib/styles'
import {EmptyState} from '../util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {logger} from '#/logger'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/queries/preferences'
import {List, ListRef} from '../util/List'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {CenteredView} from '../util/Views'
import {FeedItem} from './FeedItem'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {usePalette} from '#/lib/hooks/usePalette'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+4 -2
View File
@@ -8,7 +8,6 @@ import {useLingui} from '@lingui/react'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {ScrollProvider} from '#/lib/ScrollContext'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
sortThread,
ThreadBlocked,
@@ -17,7 +16,10 @@ import {
ThreadPost,
usePostThreadQuery,
} from '#/state/queries/post-thread'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {
useModerationOpts,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {usePalette} from 'lib/hooks/usePalette'
+1 -1
View File
@@ -15,8 +15,8 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {ThreadPost} from '#/state/queries/post-thread'
import {useModerationOpts} from '#/state/queries/preferences'
import {useComposerControls} from '#/state/shell/composer'
import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
+1 -1
View File
@@ -14,7 +14,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {useComposerControls} from '#/state/shell/composer'
import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
+1 -1
View File
@@ -12,7 +12,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {Shadow} from '#/state/cache/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
@@ -9,7 +9,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useAnalytics} from 'lib/analytics/analytics'
+1 -1
View File
@@ -25,7 +25,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePalette} from 'lib/hooks/usePalette'
import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
+2 -23
View File
@@ -1,17 +1,15 @@
import React from 'react'
import {StyleSheet, TextProps} from 'react-native'
import Svg, {
Path,
Defs,
LinearGradient,
Path,
PathProps,
Stop,
SvgProps,
PathProps,
} from 'react-native-svg'
import {Image} from 'expo-image'
import {colors} from '#/lib/styles'
import {useKawaiiMode} from '#/state/preferences/kawaii'
const ratio = 57 / 64
@@ -27,25 +25,6 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3
// @ts-ignore it's fiiiiine
const size = parseInt(rest.width || 32)
const isKawaii = useKawaiiMode()
if (isKawaii) {
return (
<Image
source={
size > 100
? require('../../../assets/kawaii.png')
: require('../../../assets/kawaii_smol.png')
}
accessibilityLabel="Bluesky"
accessibilityHint=""
accessibilityIgnoresInvertColors
style={[{height: size, aspectRatio: 1.4}]}
/>
)
}
return (
<Svg
fill="none"
+1 -1
View File
@@ -20,12 +20,12 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {moderationOptsOverrideContext} from '#/state/preferences/moderation-opts'
import {FeedNotification} from '#/state/queries/notifications/types'
import {
groupNotifications,
shouldFilterNotif,
} from '#/state/queries/notifications/util'
import {moderationOptsOverrideContext} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
+1 -1
View File
@@ -13,9 +13,9 @@ import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useAgent, useSession} from '#/state/session'
+1 -1
View File
@@ -27,9 +27,9 @@ import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearch} from '#/state/queries/actor-search'
import {useModerationOpts} from '#/state/queries/preferences'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
-13
View File
@@ -18,7 +18,6 @@ import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {emitSoftReset} from '#/state/events'
import {useKawaiiMode} from '#/state/preferences/kawaii'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useProfileQuery} from '#/state/queries/profile'
import {SessionAccount, useSession} from '#/state/session'
@@ -118,7 +117,6 @@ let DrawerContent = ({}: {}): React.ReactNode => {
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const {hasSession, currentAccount} = useSession()
const kawaii = useKawaiiMode()
// events
// =
@@ -264,17 +262,6 @@ let DrawerContent = ({}: {}): React.ReactNode => {
href="https://bsky.social/about/support/privacy-policy"
text={_(msg`Privacy Policy`)}
/>
{kawaii && (
<Text type="md" style={pal.textLight}>
Logo by{' '}
<TextLink
type="md"
href="/profile/sawaratsuki.bsky.social"
text="@sawaratsuki.bsky.social"
style={pal.link}
/>
</Text>
)}
</View>
<View style={styles.smallSpacer} />
+9 -24
View File
@@ -1,26 +1,22 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useKawaiiMode} from '#/state/preferences/kawaii'
import {useSession} from '#/state/session'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {TextLink} from 'view/com/util/Link'
import {Text} from 'view/com/util/text/Text'
import {DesktopFeeds} from './Feeds'
import {DesktopSearch} from './Search'
import {DesktopFeeds} from './Feeds'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {s} from 'lib/styles'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useSession} from '#/state/session'
export function DesktopRightNav({routeName}: {routeName: string}) {
const pal = usePalette('default')
const {_} = useLingui()
const {hasSession, currentAccount} = useSession()
const kawaii = useKawaiiMode()
const {isTablet} = useWebMediaQueries()
if (isTablet) {
return null
@@ -94,17 +90,6 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
text={_(msg`Help`)}
/>
</View>
{kawaii && (
<Text type="md" style={[pal.textLight, {marginTop: 12}]}>
Logo by{' '}
<TextLink
type="md"
href="/profile/sawaratsuki.bsky.social"
text="@sawaratsuki.bsky.social"
style={pal.link}
/>
</Text>
)}
</View>
</View>
</View>
+1 -1
View File
@@ -21,8 +21,8 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon2} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
+1 -2
View File
@@ -12,6 +12,5 @@
"view/*": ["./src/view/*"],
"crypto": ["./src/platform/crypto.ts"]
}
},
"exclude": ["bskyweb", "bskyembed", "web-build"]
}
}
-38
View File
@@ -34,18 +34,6 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
"@atproto-labs/api@^0.12.8-clipclops.0":
version "0.12.8-clipclops.0"
resolved "https://registry.yarnpkg.com/@atproto-labs/api/-/api-0.12.8-clipclops.0.tgz#1c5d41d3396e439a0b645f7e1ccf500cc4b42580"
integrity sha512-YYDtWWk6BR+aRBVja/1v+gceNK81lkmF5bi6O4pTmJhFt/321XATx/ql8uTWta4VnVThoFeNPG6nLr7hs8b9cA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0"
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/api@^0.12.3":
version "0.12.3"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17"
@@ -18981,15 +18969,6 @@ react-test-renderer@18.2.0:
react-shallow-renderer "^16.15.0"
scheduler "^0.23.0"
react-textarea-autosize@^8.5.3:
version "8.5.3"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409"
integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==
dependencies:
"@babel/runtime" "^7.20.13"
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
@@ -21362,16 +21341,6 @@ use-callback-ref@^1.3.0:
dependencies:
tslib "^2.0.0"
use-composed-ref@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
use-isomorphic-layout-effect@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-latest-callback@^0.1.5:
version "0.1.6"
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6"
@@ -21382,13 +21351,6 @@ use-latest-callback@^0.1.9:
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.9.tgz#10191dc54257e65a8e52322127643a8940271e2a"
integrity sha512-CL/29uS74AwreI/f2oz2hLTW7ZqVeV5+gxFeGudzQrgkCytrHw33G4KbnQOrRlAEzzAFXi7dDLMC9zhWcVpzmw==
use-latest@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2"
integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-sidecar@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"