[Clipclops] New clipclop dialog (#3750)

* add new routes with placeholder screens

* add clops list

* add a clop input

* add some better padding to the clops

* some more adjustments

* add rnkc

* implement rnkc

* implement rnkc

* be a little less weird about it

* rename clop stuff

* rename more clop

* one more

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* replace hailey's types

* use codegen'd types in components

* add FAB

* new chat dialog

* error + default search term

* fix typo

* fix web styles

* optimistically set chat data

* use cursor instead of last rev

* [Clipclops] Temp codegenerated lexicon (#3749)

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* add clop service URL hook

* add dm service url storage

* use context

* use context for service url (temp)

* remove log

* cleanup merge

* fix merge error

* disable hack

* sender-based message styles

* temporary filter

* merge cleanup

* add `hideBackButton`

* rm unneeded return

* tried to be smart

* hide go back button

* use `searchActorTypeahead` instead

---------

Co-authored-by: Hailey <me@haileyok.com>
zio/stable
Samuel Newman 2024-04-30 17:43:57 +01:00 committed by GitHub
parent 2b7d796ca9
commit bcd3678067
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 352 additions and 56 deletions

View File

@ -17,12 +17,14 @@ export function Error({
message,
onRetry,
onGoBack: onGoBackProp,
hideBackButton,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
@ -89,6 +91,7 @@ export function Error({
</ButtonText>
</Button>
)}
{!hideBackButton && (
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
@ -100,6 +103,7 @@ export function Error({
<Trans>Go Back</Trans>
</ButtonText>
</Button>
)}
</View>
</CenteredView>
)

View File

@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({
emptyType = 'page',
onRetry,
onGoBack,
hideBackButton,
sideBorders,
}: {
isLoading: boolean
@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({
emptyType?: 'page' | 'results'
onRetry?: () => Promise<unknown>
onGoBack?: () => void
hideBackButton?: boolean
sideBorders?: boolean
}): React.ReactNode => {
const t = useTheme()
@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
}
@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({
}
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
sideBorders={sideBorders}
/>
)

View File

@ -0,0 +1,233 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {Keyboard, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
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/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'
import {UserAvatar} from '#/view/com/util/UserAvatar'
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 {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'
export function NewChat({onNewChat}: {onNewChat: (chatId: string) => void}) {
const control = Dialog.useDialogControl()
const t = useTheme()
const {_} = useLingui()
const {mutate: createChat} = useGetChatFromMembers({
onSuccess: data => {
onNewChat(data.chat.id)
},
onError: error => {
Toast.show(error.message)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
},
[control, createChat],
)
return (
<>
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Envelope size="xl" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Handle />
<SearchablePeopleList onCreateChat={onCreateChat} />
</Dialog.Outer>
</>
)
}
function SearchablePeopleList({
onCreateChat,
}: {
onCreateChat: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const [searchText, setSearchText] = useState('')
const {
data: actorAutocompleteData,
isFetching,
isError,
refetch,
} = useActorAutocompleteQuery(searchText, true)
const renderItem = useCallback(
({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
if (!moderationOpts) return null
const moderation = moderateProfile(profile, moderationOpts)
return (
<Button
label={profile.displayName || sanitizeHandle(profile.handle)}
onPress={() => onCreateChat(profile.did)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.px_md,
a.py_sm,
a.gap_md,
a.align_center,
a.flex_row,
a.rounded_sm,
pressed
? t.atoms.bg_contrast_25
: hovered
? t.atoms.bg_contrast_50
: t.atoms.bg,
]}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={{flex: 1}}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text style={t.atoms.text_contrast_high} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</View>
)}
</Button>
)
},
[
moderationOpts,
onCreateChat,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
t.atoms.bg,
t.atoms.text,
t.atoms.text_contrast_high,
],
)
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<Dialog.Close />
<Text
style={[
a.text_2xl,
a.font_bold,
a.leading_tight,
a.pb_lg,
web(a.pt_lg),
]}>
<Trans>Start a new chat</Trans>
</Text>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search profiles`)}
placeholder={_(msg`Search`)}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
maxLength={50}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _, control, searchText])
return (
<Dialog.InnerFlatList
ref={listRef}
data={actorAutocompleteData}
renderItem={renderItem}
ListHeaderComponent={
<>
{listHeader}
{searchText.length > 0 && !actorAutocompleteData?.length && (
<ListMaybePlaceholder
isLoading={isFetching}
isError={isError}
onRetry={refetch}
hideBackButton={true}
emptyType="results"
sideBorders={false}
emptyMessage={
isError
? _(msg`No search results found for "${searchText}".`)
: _(msg`Could not load profiles. Please try again later.`)
}
/>
)}
</>
}
stickyHeaderIndices={[0]}
keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
// @ts-expect-error web only
style={isWeb && {minHeight: '100vh'}}
onScrollBeginDrag={() => Keyboard.dismiss()}
/>
)
}

View File

@ -1,12 +1,16 @@
import React from 'react'
import {View} from 'react-native'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) {
const t = useTheme()
const {getAgent} = useAgent()
const fromMe = item.sender?.did === getAgent().session?.did
return (
<View
@ -15,13 +19,17 @@ export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) {
a.px_md,
a.my_xs,
a.rounded_md,
fromMe ? a.self_end : a.self_start,
{
backgroundColor: t.palette.primary_500,
backgroundColor: fromMe
? t.palette.primary_500
: t.palette.contrast_50,
maxWidth: '65%',
borderRadius: 17,
},
]}>
<Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}>
<Text
style={[a.text_md, a.leading_snug, fromMe && {color: t.palette.white}]}>
{item.text}
</Text>
</View>

View File

@ -1,5 +1,6 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {Alert, FlatList, View, ViewToken} from 'react-native'
import {FlatList, View, ViewToken} from 'react-native'
import {Alert} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {isWeb} from 'platform/detection'
@ -64,6 +65,7 @@ export function MessagesList({chatId}: {chatId: string}) {
const totalMessages = useRef(10)
// TODO later
const [_, setShowSpinner] = useState(false)
// Query Data
@ -147,6 +149,8 @@ export function MessagesList({chatId}: {chatId: string}) {
},
)
totalMessages.current = filtered.length
return filtered
}, [chat])
return (
@ -162,7 +166,7 @@ export function MessagesList({chatId}: {chatId: string}) {
contentContainerStyle={{paddingHorizontal: 10}}
// 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
// this...probably.
initialNumToRender={20}
// Same with the max to render per batch. Let's be safe for now though.
maxToRenderPerBatch={25}
@ -175,7 +179,6 @@ export function MessagesList({chatId}: {chatId: string}) {
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
// This is actually a header since we are inverted!
ListFooterComponent={<MaybeLoader isLoading={false} />}
removeClippedSubviews={true}
ref={flatListRef}

View File

@ -1,4 +1,4 @@
import React, {useCallback, useState} from 'react'
import React, {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@ -20,10 +20,11 @@ import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '
import {Link} from '#/components/Link'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {NewChat} from '../../../components/dms/NewChat'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
export function MessagesListScreen({}: Props) {
export function MessagesListScreen({navigation}: Props) {
const {_} = useLingui()
const t = useTheme()
@ -53,14 +54,14 @@ export function MessagesListScreen({}: Props) {
const isError = !!error
const conversations = React.useMemo(() => {
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flat()
}
return []
}, [data])
const onRefresh = React.useCallback(async () => {
const onRefresh = useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
@ -70,7 +71,7 @@ export function MessagesListScreen({}: Props) {
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
@ -79,11 +80,18 @@ export function MessagesListScreen({}: Props) {
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const onNewChat = useCallback(
(conversation: string) =>
navigation.navigate('MessagesConversation', {conversation}),
[navigation],
)
const gate = useGate()
if (!gate('dms')) return <ClipClopGate />
if (conversations.length < 1) {
return (
<>
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
@ -94,11 +102,13 @@ export function MessagesListScreen({}: Props) {
errorMessage={cleanError(error)}
onRetry={isError ? refetch : undefined}
/>
<NewChat onNewChat={onNewChat} />
</>
)
}
return (
<View>
<View style={a.flex_1}>
<ViewHeader
title={_(msg`Messages`)}
showOnDesktop
@ -106,6 +116,7 @@ export function MessagesListScreen({}: Props) {
showBorder
canGoBack={false}
/>
<NewChat onNewChat={onNewChat} />
<List
data={conversations}
renderItem={({item}) => {

View File

@ -1,20 +1,24 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useSession} from 'state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
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 {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
/**
* TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
* (and do not try this at home)
*/
function createHeaders(did: string) {
const useHeaders = () => {
const {getAgent} = useAgent()
return {
Authorization: did,
get Authorization() {
return getAgent().session!.did
},
}
}
@ -27,10 +31,8 @@ type Chat = {
export function useChat(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
const {currentAccount} = useSession()
const did = currentAccount?.did ?? ''
return useQuery({
queryKey: ['chat', chatId],
@ -44,7 +46,7 @@ export function useChat(chatId: string) {
const messagesResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`,
{
headers: createHeaders(did),
headers,
},
)
@ -56,7 +58,7 @@ export function useChat(chatId: string) {
const chatResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
{
headers: createHeaders(did),
headers,
},
)
@ -90,10 +92,8 @@ export function createTempId() {
export function useSendMessageMutation(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
const {currentAccount} = useSession()
const did = currentAccount?.did ?? ''
return useMutation<
TempDmChatDefs.Message,
@ -108,7 +108,7 @@ export function useSendMessageMutation(chatId: string) {
{
method: 'POST',
headers: {
...createHeaders(did),
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
@ -130,8 +130,10 @@ export function useSendMessageMutation(chatId: string) {
...prev,
messages: [
{
$type: 'temp.dm.defs#messageView',
id: variables.tempId,
text: variables.message,
sender: {did: headers.Authorization}, // TODO a real DID get
},
...prev.messages,
],
@ -165,10 +167,8 @@ export function useSendMessageMutation(chatId: string) {
export function useChatLogQuery() {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
const {currentAccount} = useSession()
const did = currentAccount?.did ?? ''
return useQuery({
queryKey: ['chatLog'],
@ -183,7 +183,7 @@ export function useChatLogQuery() {
prevLog?.cursor ?? ''
}`,
{
headers: createHeaders(did),
headers,
},
)
@ -193,13 +193,10 @@ export function useChatLogQuery() {
(await response.json()) as TempDmChatGetChatLog.OutputSchema
for (const log of json.logs) {
if (TempDmChatDefs.isLogDeleteMessage(log)) {
if (TempDmChatDefs.isLogCreateMessage(log)) {
queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => {
// What to do in this case
if (!prev) return
// HACK we don't know who the creator of a message is, so just filter by id for now
if (prev.messages.find(m => m.id === log.message.id)) return prev
// TODO hack filter out duplicates
if (prev?.messages.find(m => m.id === log.message.id)) return
return {
...prev,
@ -217,3 +214,39 @@ export function useChatLogQuery() {
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,
})
}

View File

@ -791,7 +791,7 @@ export function SettingsScreen({}: Props) {
<TextField.Input
value={dmServiceUrl}
onChangeText={(text: string) => {
if (text.endsWith('/')) {
if (text.length > 9 && text.endsWith('/')) {
text = text.slice(0, -1)
}
setDmServiceUrl(text)