2efdc3fe35
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
618 lines
21 KiB
TypeScript
618 lines
21 KiB
TypeScript
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
|
import {View} from 'react-native'
|
|
import {useAnimatedRef} from 'react-native-reanimated'
|
|
import {type ChatBskyActorGetStatus, ChatBskyConvoDefs} from '@atproto/api'
|
|
import {Trans, useLingui} from '@lingui/react/macro'
|
|
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
|
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
|
|
|
import {useAppState} from '#/lib/appState'
|
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
|
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
|
|
import {cleanError} from '#/lib/strings/errors'
|
|
import {logger} from '#/logger'
|
|
import {listenSoftReset} from '#/state/events'
|
|
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
|
|
import {useMessagesEventBus} from '#/state/messages/events'
|
|
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
|
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
|
import {EmptyState} from '#/view/com/util/EmptyState'
|
|
import {List, type ListRef} from '#/view/com/util/List'
|
|
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
|
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
|
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
import {type DialogControlProps, useDialogControl} from '#/components/Dialog'
|
|
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
|
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
|
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
|
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
|
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
|
|
import {
|
|
MessagePlus_Stroke2_Corner0_Rounded as MessagePlusIcon,
|
|
MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon,
|
|
} from '#/components/icons/Message'
|
|
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
|
|
import * as Layout from '#/components/Layout'
|
|
import {Link} from '#/components/Link'
|
|
import {ListFooter} from '#/components/Lists'
|
|
import {Text} from '#/components/Typography'
|
|
import {useAgeAssurance} from '#/ageAssurance'
|
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
|
import {ChatDisabled} from './components/ChatDisabled'
|
|
import {ChatListItem} from './components/ChatListItem'
|
|
import {InboxRequests} from './components/InboxRequests'
|
|
import {useIsWithinSplitView} from './components/splitView/context'
|
|
import {splitViewLeftScroll} from './components/splitView/leftColumnScroll'
|
|
|
|
type ChatStatus = ChatBskyActorGetStatus.OutputSchema
|
|
|
|
type ListItem = {
|
|
type: 'CONVERSATION'
|
|
conversation: ChatBskyConvoDefs.ConvoView
|
|
selected: boolean
|
|
}
|
|
|
|
function renderItem({item}: {item: ListItem}) {
|
|
return <ChatListItem convo={item.conversation} selected={item.selected} />
|
|
}
|
|
|
|
function keyExtractor(item: ListItem) {
|
|
return item.conversation.id
|
|
}
|
|
|
|
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
|
|
|
|
export function MessagesScreen(props: Props) {
|
|
const {t: l} = useLingui()
|
|
const aaCopy = useAgeAssuranceCopy()
|
|
const aa = useAgeAssurance()
|
|
|
|
return (
|
|
<AgeRestrictedScreen
|
|
screenTitle={l`Chats`}
|
|
infoText={aaCopy.chatsInfoText}
|
|
rightHeaderSlot={
|
|
aa.flags.chatDisabled ? null : (
|
|
<Link
|
|
to="/messages/settings"
|
|
label={l`Chat settings`}
|
|
size="small"
|
|
color="secondary">
|
|
<ButtonText>
|
|
<Trans>Chat settings</Trans>
|
|
</ButtonText>
|
|
</Link>
|
|
)
|
|
}>
|
|
<MessagesScreenInner {...props} />
|
|
</AgeRestrictedScreen>
|
|
)
|
|
}
|
|
|
|
export function MessagesScreenInner({navigation, route}: Props) {
|
|
const {isWithinSplitView} = useIsWithinSplitView()
|
|
const {t: l} = useLingui()
|
|
const t = useTheme()
|
|
const newChatControl = useDialogControl()
|
|
const {data: chatStatus} = useChatActorStatusQuery()
|
|
const pushToConversation = route.params?.pushToConversation
|
|
const pushToNewGroupChat = route.params?.pushToNewGroupChat
|
|
// Tracks whether the next new-chat dialog open should start directly on the
|
|
// group-chat creation step. Set when deep-linked via `pushToNewGroupChat`,
|
|
// and reset to `false` whenever the dialog is opened through the normal FAB/
|
|
// button path so it never gets stuck in group mode.
|
|
const [startNewChatInGroupChat, setStartNewChatInGroupChat] = useState(false)
|
|
|
|
// Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
|
|
// this tab. We should immediately push to the conversation after pressing the notification.
|
|
// After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if
|
|
// the conversation is the same as before
|
|
useEffect(() => {
|
|
if (pushToConversation) {
|
|
navigation.navigate('MessagesConversation', {
|
|
conversation: pushToConversation,
|
|
})
|
|
navigation.setParams({pushToConversation: undefined})
|
|
}
|
|
}, [navigation, pushToConversation])
|
|
|
|
// Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future)
|
|
// but only when the screen is active
|
|
const messagesBus = useMessagesEventBus()
|
|
const state = useAppState()
|
|
const isActive = state === 'active'
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (isActive) {
|
|
const unsub = messagesBus.requestPollInterval(
|
|
MESSAGE_SCREEN_POLL_INTERVAL,
|
|
)
|
|
return () => unsub()
|
|
}
|
|
}, [messagesBus, isActive]),
|
|
)
|
|
|
|
const onNewChat = useCallback(
|
|
(conversation: string) =>
|
|
navigation.navigate('MessagesConversation', {conversation}),
|
|
[navigation],
|
|
)
|
|
|
|
const openChatControl = useCallback(() => {
|
|
setStartNewChatInGroupChat(false)
|
|
newChatControl.open()
|
|
}, [newChatControl])
|
|
|
|
const requireEmailVerification = useRequireEmailVerification()
|
|
const wrappedOpenChatControl = requireEmailVerification(openChatControl, {
|
|
instructions: [
|
|
<Trans key="new-chat">
|
|
Before you can message another user, you must first verify your email.
|
|
</Trans>,
|
|
],
|
|
})
|
|
|
|
// Deep link into the group-chat creation step of the new-chat dialog. Mirrors
|
|
// the `pushToConversation` pattern: open the dialog (respecting the same
|
|
// email-verification gating as the normal new-chat button) starting directly
|
|
// in group mode, then clear the param so it can fire again later.
|
|
const openGroupChatControl = useCallback(() => {
|
|
setStartNewChatInGroupChat(true)
|
|
newChatControl.open()
|
|
}, [newChatControl])
|
|
const wrappedOpenGroupChatControl = requireEmailVerification(
|
|
openGroupChatControl,
|
|
{
|
|
instructions: [
|
|
<Trans key="new-group-chat">
|
|
Before you can message another user, you must first verify your email.
|
|
</Trans>,
|
|
],
|
|
},
|
|
)
|
|
// Stable reference to the (otherwise per-render) opener so the effect below
|
|
// doesn't list it as a dependency - if it did, clearing the param would
|
|
// re-run the effect and its cleanup would cancel the pending open.
|
|
const openGroupChat = useNonReactiveCallback(wrappedOpenGroupChatControl)
|
|
// Deep link into the group-chat creation step of the new-chat dialog. The
|
|
// dialog control isn't attached synchronously when navigating onto this
|
|
// screen, so defer the open by a tick. We clear the param *after* opening
|
|
// (inside the timeout) so clearing doesn't cancel the pending open.
|
|
useEffect(() => {
|
|
if (!pushToNewGroupChat) return
|
|
const timeout = setTimeout(() => {
|
|
openGroupChat()
|
|
if (IS_WEB) {
|
|
// `navigation.setParams({pushToNewGroupChat: undefined})` serializes the
|
|
// literal string "undefined" into the query on web (see router build()),
|
|
// so strip the param with the history API instead.
|
|
const url = new URL(window.location.href)
|
|
url.searchParams.delete('pushToNewGroupChat')
|
|
history.replaceState(null, '', url.pathname + url.search + url.hash)
|
|
} else {
|
|
navigation.setParams({pushToNewGroupChat: undefined})
|
|
}
|
|
}, 100)
|
|
return () => clearTimeout(timeout)
|
|
}, [navigation, pushToNewGroupChat, openGroupChat])
|
|
|
|
if (isWithinSplitView) {
|
|
return (
|
|
<>
|
|
<EmptyState
|
|
message={l`Say hi to someone`}
|
|
icon={BubbleSmileIcon}
|
|
textStyle={t.atoms.text}
|
|
iconColor={t.atoms.text.color}
|
|
iconSize="4xl"
|
|
button={
|
|
chatStatus?.chatDisabled
|
|
? undefined
|
|
: {
|
|
label: l`New chat`,
|
|
text: l`New chat`,
|
|
onPress: wrappedOpenChatControl,
|
|
size: 'small',
|
|
color: 'primary',
|
|
icon: MessagePlusIcon,
|
|
}
|
|
}
|
|
style={[a.h_full, a.justify_center, a.pb_5xl]}
|
|
/>
|
|
<NewChat
|
|
onNewChat={onNewChat}
|
|
control={newChatControl}
|
|
startInGroupChat={startNewChatInGroupChat}
|
|
onClose={() => setStartNewChatInGroupChat(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Layout.Screen testID="messagesScreen">
|
|
<Header newChatControl={newChatControl} chatStatus={chatStatus} />
|
|
<ChatList newChatControl={newChatControl} chatStatus={chatStatus} />
|
|
<NewChat
|
|
onNewChat={onNewChat}
|
|
control={newChatControl}
|
|
startInGroupChat={startNewChatInGroupChat}
|
|
onClose={() => setStartNewChatInGroupChat(false)}
|
|
/>
|
|
</Layout.Screen>
|
|
)
|
|
}
|
|
|
|
export function ChatList({
|
|
selectedChat,
|
|
newChatControl,
|
|
chatStatus,
|
|
}: {
|
|
selectedChat?: string
|
|
newChatControl: DialogControlProps
|
|
chatStatus: ChatStatus | undefined
|
|
}) {
|
|
const t = useTheme()
|
|
const {t: l} = useLingui()
|
|
const aa = useAgeAssurance()
|
|
const scrollElRef: ListRef = useAnimatedRef()
|
|
const {isWithinSplitView} = useIsWithinSplitView()
|
|
|
|
const openChatControl = useCallback(() => {
|
|
newChatControl.open()
|
|
}, [newChatControl])
|
|
|
|
const requireEmailVerification = useRequireEmailVerification()
|
|
const wrappedOpenChatControl = requireEmailVerification(openChatControl, {
|
|
instructions: [
|
|
<Trans key="new-chat">
|
|
Before you can message another user, you must first verify your email.
|
|
</Trans>,
|
|
],
|
|
})
|
|
|
|
const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
|
|
const [isPTRing, setIsPTRing] = useState(false)
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
isError,
|
|
error,
|
|
refetch,
|
|
} = useListConvosQuery({status: 'accepted'})
|
|
|
|
const {refetch: refetchInbox} = useListConvosQuery({
|
|
status: 'request',
|
|
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
|
})
|
|
|
|
useRefreshOnFocus(refetch)
|
|
useRefreshOnFocus(refetchInbox)
|
|
|
|
const conversations = useMemo(() => {
|
|
if (data?.pages) {
|
|
const conversations = data.pages.flatMap(page => page.convos)
|
|
|
|
return conversations.map(
|
|
convo =>
|
|
({
|
|
type: 'CONVERSATION',
|
|
conversation: convo,
|
|
selected: convo.id === selectedChat,
|
|
}) as const,
|
|
) satisfies ListItem[]
|
|
}
|
|
return []
|
|
}, [data, selectedChat])
|
|
|
|
const onRefresh = useCallback(async () => {
|
|
setIsPTRing(true)
|
|
try {
|
|
await Promise.all([refetch(), refetchInbox()])
|
|
} catch (err) {
|
|
logger.error('Failed to refresh conversations', {message: err})
|
|
}
|
|
setIsPTRing(false)
|
|
}, [refetch, refetchInbox, setIsPTRing])
|
|
|
|
const onEndReached = useCallback(async () => {
|
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
|
try {
|
|
await fetchNextPage()
|
|
} catch (err) {
|
|
logger.error('Failed to load more conversations', {message: err})
|
|
}
|
|
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
|
|
|
const onSoftReset = useCallback(async () => {
|
|
scrollElRef.current?.scrollToOffset({
|
|
animated: IS_NATIVE,
|
|
offset: 0,
|
|
})
|
|
if (isWithinSplitView) {
|
|
splitViewLeftScroll.current = 0
|
|
restoredRef.current = true
|
|
}
|
|
try {
|
|
await refetch()
|
|
} catch (err) {
|
|
logger.error('Failed to refresh conversations', {message: err})
|
|
}
|
|
}, [scrollElRef, refetch, isWithinSplitView])
|
|
|
|
// Restore the saved scroll offset once the list has rendered enough
|
|
// content to honor it. Module-level ref survives ChatList re-mounts that
|
|
// happen on in-splitview navigation (see leftColumnScroll.ts).
|
|
const restoredRef = useRef(false)
|
|
const onContentSizeChange = useCallback(
|
|
(_w: number, h: number) => {
|
|
if (!isWithinSplitView || restoredRef.current) return
|
|
const offset = splitViewLeftScroll.current
|
|
if (offset > 0 && h >= offset) {
|
|
scrollElRef.current?.scrollToOffset({offset, animated: false})
|
|
restoredRef.current = true
|
|
}
|
|
},
|
|
[isWithinSplitView, scrollElRef],
|
|
)
|
|
|
|
const isScreenFocused = useIsFocused()
|
|
useEffect(() => {
|
|
if (!isScreenFocused) {
|
|
return
|
|
}
|
|
return listenSoftReset(() => void onSoftReset())
|
|
}, [onSoftReset, isScreenFocused])
|
|
|
|
if (conversations.length === 0) {
|
|
return (
|
|
<Layout.Center style={web({minHeight: '100%'})}>
|
|
{isLoading ? (
|
|
<ChatListLoadingPlaceholder />
|
|
) : (
|
|
<>
|
|
{isError ? (
|
|
<>
|
|
<View style={[a.pt_3xl, a.align_center]}>
|
|
<CircleInfoIcon
|
|
width={48}
|
|
fill={t.atoms.text_contrast_low.color}
|
|
/>
|
|
<Text
|
|
style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}>
|
|
<Trans>Whoops!</Trans>
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
a.text_md,
|
|
a.pb_xl,
|
|
a.text_center,
|
|
a.leading_snug,
|
|
t.atoms.text_contrast_medium,
|
|
{maxWidth: 360},
|
|
]}>
|
|
{cleanError(error) || l`Failed to load conversations`}
|
|
</Text>
|
|
|
|
<Button
|
|
label={l`Reload conversations`}
|
|
size="small"
|
|
color="secondary_inverted"
|
|
onPress={() => void refetch()}>
|
|
<ButtonText>
|
|
<Trans>Retry</Trans>
|
|
</ButtonText>
|
|
<ButtonIcon icon={RetryIcon} />
|
|
</Button>
|
|
</View>
|
|
</>
|
|
) : isWithinSplitView ? (
|
|
<EmptyState
|
|
message={l`Inbox empty`}
|
|
icon={InboxLargeIcon}
|
|
iconSize="4xl"
|
|
textStyle={t.atoms.text}
|
|
iconColor={t.atoms.text.color}
|
|
style={web([a.h_full, a.justify_center, {paddingBottom: 120}])}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
message={l`Say hi to someone`}
|
|
icon={BubbleSmileIcon}
|
|
textStyle={t.atoms.text}
|
|
iconColor={t.atoms.text.color}
|
|
iconSize="4xl"
|
|
button={
|
|
chatStatus?.chatDisabled
|
|
? undefined
|
|
: {
|
|
label: l`New chat`,
|
|
text: l`New chat`,
|
|
onPress: wrappedOpenChatControl,
|
|
size: 'small',
|
|
color: 'primary',
|
|
icon: MessagePlusIcon,
|
|
}
|
|
}
|
|
style={[a.h_full, {paddingTop: '20%'}]}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</Layout.Center>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<List
|
|
ref={scrollElRef}
|
|
data={conversations}
|
|
renderItem={renderItem}
|
|
keyExtractor={keyExtractor}
|
|
refreshing={isPTRing}
|
|
onRefresh={() => void onRefresh()}
|
|
onEndReached={() => void onEndReached()}
|
|
ListHeaderComponent={
|
|
chatStatus?.chatDisabled ? (
|
|
<ChatDisabled shape="banner" style={[isWithinSplitView && a.mb_sm]} />
|
|
) : undefined
|
|
}
|
|
ListFooterComponent={
|
|
<ListFooter
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
error={cleanError(error)}
|
|
onRetry={fetchNextPage}
|
|
style={{borderColor: 'transparent'}}
|
|
hasNextPage={hasNextPage}
|
|
/>
|
|
}
|
|
onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
|
|
onContentSizeChange={onContentSizeChange}
|
|
initialNumToRender={initialNumToRender}
|
|
windowSize={11}
|
|
desktopFixedHeight
|
|
sideBorders={false}
|
|
disableFullWindowScroll={isWithinSplitView}
|
|
style={
|
|
isWithinSplitView && [
|
|
a.w_full,
|
|
web({
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
|
}),
|
|
]
|
|
}
|
|
contentContainerStyle={
|
|
isWithinSplitView && !chatStatus?.chatDisabled && a.py_sm
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function Header({
|
|
newChatControl,
|
|
chatStatus,
|
|
}: {
|
|
newChatControl: DialogControlProps
|
|
chatStatus: ChatStatus | undefined
|
|
}) {
|
|
const {t: l} = useLingui()
|
|
const {gtMobile} = useBreakpoints()
|
|
const aa = useAgeAssurance()
|
|
const requireEmailVerification = useRequireEmailVerification()
|
|
const {isWithinSplitView} = useIsWithinSplitView()
|
|
|
|
// In split view, the left column (and this header) stays mounted while the
|
|
// right column shows the selected route. Pushing would stack duplicate routes
|
|
// on repeated clicks, so navigate instead to dedupe by route + params.
|
|
const action = isWithinSplitView ? 'navigate' : 'push'
|
|
|
|
const {data: unreadInboxData, hasNextPage: hasMoreRequests} =
|
|
useListConvosQuery({
|
|
status: 'request',
|
|
readState: 'unread',
|
|
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
|
})
|
|
|
|
const inboxAllConvos =
|
|
unreadInboxData?.pages
|
|
.flatMap(page => page.convos)
|
|
.filter(
|
|
convo =>
|
|
!convo.muted &&
|
|
convo.members.every(member => member.handle !== 'missing.invalid') &&
|
|
(ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
|
? !aa.flags.groupChatDisabled
|
|
: true),
|
|
) ?? []
|
|
|
|
const openChatControl = useCallback(() => {
|
|
newChatControl.open()
|
|
}, [newChatControl])
|
|
const wrappedOpenChatControl = requireEmailVerification(openChatControl, {
|
|
instructions: [
|
|
<Trans key="new-chat">
|
|
Before you can message another user, you must first verify your email.
|
|
</Trans>,
|
|
],
|
|
})
|
|
|
|
return (
|
|
<Layout.Header.Outer>
|
|
{gtMobile ? (
|
|
<>
|
|
<Layout.Header.Content align="left">
|
|
<Layout.Header.TitleText>
|
|
<Trans>Chats</Trans>
|
|
</Layout.Header.TitleText>
|
|
</Layout.Header.Content>
|
|
|
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
<InboxRequests
|
|
count={inboxAllConvos.length}
|
|
more={hasMoreRequests}
|
|
variant="solid"
|
|
action={action}
|
|
/>
|
|
<Link
|
|
to="/messages/settings"
|
|
action={action}
|
|
label={l`Chat settings`}
|
|
size="small"
|
|
color="secondary"
|
|
shape="round"
|
|
style={[a.justify_center]}>
|
|
<ButtonIcon icon={SettingsIcon} />
|
|
</Link>
|
|
{!chatStatus?.chatDisabled && (
|
|
<Button
|
|
label={l`New chat`}
|
|
color="primary"
|
|
size="small"
|
|
shape="round"
|
|
onPress={wrappedOpenChatControl}>
|
|
<ButtonIcon icon={NewChatIcon} />
|
|
</Button>
|
|
)}
|
|
</View>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Layout.Header.MenuButton />
|
|
<Layout.Header.Content align="left">
|
|
<Layout.Header.TitleText>
|
|
<Trans>Chats</Trans>
|
|
</Layout.Header.TitleText>
|
|
</Layout.Header.Content>
|
|
<InboxRequests
|
|
count={inboxAllConvos.length}
|
|
more={hasMoreRequests}
|
|
variant="ghost"
|
|
/>
|
|
<Layout.Header.Slot>
|
|
<Link
|
|
to="/messages/settings"
|
|
label={l`Chat settings`}
|
|
size="small"
|
|
variant="ghost"
|
|
color="secondary"
|
|
shape="round"
|
|
style={[a.justify_center]}>
|
|
<ButtonIcon icon={SettingsIcon} size="lg" />
|
|
</Link>
|
|
</Layout.Header.Slot>
|
|
</>
|
|
)}
|
|
</Layout.Header.Outer>
|
|
)
|
|
}
|