516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
|
|
import { FC, useCallback, useEffect, useMemo, useState } from "react"
|
||
|
|
import {
|
||
|
|
ActivityIndicator,
|
||
|
|
Dimensions,
|
||
|
|
RefreshControl,
|
||
|
|
ScrollView,
|
||
|
|
TextStyle,
|
||
|
|
TouchableOpacity,
|
||
|
|
View,
|
||
|
|
ViewStyle,
|
||
|
|
} from "react-native"
|
||
|
|
|
||
|
|
import { Button } from "@/components/Button"
|
||
|
|
import { Dialog } from "@/components/Dialog"
|
||
|
|
import { Icon } from "@/components/Icon"
|
||
|
|
import { Screen } from "@/components/Screen"
|
||
|
|
import { Skeleton, SkeletonContainer } from "@/components/Skeleton"
|
||
|
|
import { Text } from "@/components/Text"
|
||
|
|
import { useAuth } from "@/context/AuthContext"
|
||
|
|
import { translate } from "@/i18n/translate"
|
||
|
|
import { AppStackScreenProps } from "@/navigators/navigationTypes"
|
||
|
|
import { authApi } from "@/services/api/authApi"
|
||
|
|
import type { Session } from "@/services/api/authTypes"
|
||
|
|
import { useAppTheme } from "@/theme/context"
|
||
|
|
import { $styles } from "@/theme/styles"
|
||
|
|
import type { ThemedStyle } from "@/theme/types"
|
||
|
|
import { s } from "@/utils/responsive"
|
||
|
|
import { useHeader } from "@/utils/useHeader"
|
||
|
|
|
||
|
|
export const SessionManagementScreen: FC<AppStackScreenProps<"SessionManagement">> =
|
||
|
|
function SessionManagementScreen({ navigation }) {
|
||
|
|
const { themed, theme } = useAppTheme()
|
||
|
|
const { accessToken } = useAuth()
|
||
|
|
|
||
|
|
// Session state
|
||
|
|
const [sessions, setSessions] = useState<Session[]>([])
|
||
|
|
const [isLoading, setIsLoading] = useState(false)
|
||
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||
|
|
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
|
||
|
|
const [isRevokingAll, setIsRevokingAll] = useState(false)
|
||
|
|
|
||
|
|
// Dialog state
|
||
|
|
const [confirmDialogVisible, setConfirmDialogVisible] = useState(false)
|
||
|
|
const [confirmAllDialogVisible, setConfirmAllDialogVisible] = useState(false)
|
||
|
|
const [successDialogVisible, setSuccessDialogVisible] = useState(false)
|
||
|
|
const [successMessage, setSuccessMessage] = useState("")
|
||
|
|
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null)
|
||
|
|
|
||
|
|
useHeader(
|
||
|
|
{
|
||
|
|
title: translate("securityScreen:activeSessions"),
|
||
|
|
leftIcon: "back",
|
||
|
|
onLeftPress: () => navigation.goBack(),
|
||
|
|
},
|
||
|
|
[],
|
||
|
|
)
|
||
|
|
|
||
|
|
// Fetch sessions
|
||
|
|
const fetchSessions = useCallback(
|
||
|
|
async (showLoading = true) => {
|
||
|
|
if (!accessToken) return
|
||
|
|
|
||
|
|
if (showLoading) setIsLoading(true)
|
||
|
|
try {
|
||
|
|
const result = await authApi.getSessions(accessToken, { active: true })
|
||
|
|
if (result.kind === "ok" && result.data.data?.sessions) {
|
||
|
|
setSessions(result.data.data.sessions)
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
if (__DEV__) console.error("Failed to fetch sessions:", e)
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false)
|
||
|
|
setIsRefreshing(false)
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[accessToken],
|
||
|
|
)
|
||
|
|
|
||
|
|
// Initial fetch
|
||
|
|
useEffect(() => {
|
||
|
|
fetchSessions()
|
||
|
|
}, [fetchSessions])
|
||
|
|
|
||
|
|
// Pull to refresh
|
||
|
|
const onRefresh = useCallback(() => {
|
||
|
|
setIsRefreshing(true)
|
||
|
|
fetchSessions(false)
|
||
|
|
}, [fetchSessions])
|
||
|
|
|
||
|
|
// Show confirm dialog for single session
|
||
|
|
const handleRevokeSession = useCallback(
|
||
|
|
(sessionId: string) => {
|
||
|
|
if (!accessToken) return
|
||
|
|
setPendingSessionId(sessionId)
|
||
|
|
setConfirmDialogVisible(true)
|
||
|
|
},
|
||
|
|
[accessToken],
|
||
|
|
)
|
||
|
|
|
||
|
|
// Actually revoke a single session
|
||
|
|
const confirmRevokeSession = useCallback(async () => {
|
||
|
|
if (!accessToken || !pendingSessionId) return
|
||
|
|
|
||
|
|
setConfirmDialogVisible(false)
|
||
|
|
setRevokingSessionId(pendingSessionId)
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await authApi.revokeSession(accessToken, pendingSessionId)
|
||
|
|
if (result.kind === "ok") {
|
||
|
|
setSessions((prev) => prev.filter((s) => s.id !== pendingSessionId))
|
||
|
|
setSuccessMessage(translate("securityScreen:sessionRevoked"))
|
||
|
|
setSuccessDialogVisible(true)
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
if (__DEV__) console.error("Failed to revoke session:", e)
|
||
|
|
} finally {
|
||
|
|
setRevokingSessionId(null)
|
||
|
|
setPendingSessionId(null)
|
||
|
|
}
|
||
|
|
}, [accessToken, pendingSessionId])
|
||
|
|
|
||
|
|
// Show confirm dialog for all other sessions
|
||
|
|
const handleRevokeAllOther = useCallback(() => {
|
||
|
|
if (!accessToken) return
|
||
|
|
setConfirmAllDialogVisible(true)
|
||
|
|
}, [accessToken])
|
||
|
|
|
||
|
|
// Actually revoke all other sessions
|
||
|
|
const confirmRevokeAllOther = useCallback(async () => {
|
||
|
|
if (!accessToken) return
|
||
|
|
|
||
|
|
setConfirmAllDialogVisible(false)
|
||
|
|
setIsRevokingAll(true)
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await authApi.revokeOtherSessions(accessToken)
|
||
|
|
if (result.kind === "ok") {
|
||
|
|
const revokedCount = result.data.data?.revokedCount || 0
|
||
|
|
// Keep only current session
|
||
|
|
setSessions((prev) => prev.filter((s) => s.isCurrent))
|
||
|
|
if (revokedCount > 0) {
|
||
|
|
setSuccessMessage(translate("securityScreen:sessionsRevoked", { count: revokedCount }))
|
||
|
|
setSuccessDialogVisible(true)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
if (__DEV__) console.error("Failed to revoke other sessions:", e)
|
||
|
|
} finally {
|
||
|
|
setIsRevokingAll(false)
|
||
|
|
}
|
||
|
|
}, [accessToken])
|
||
|
|
|
||
|
|
// Get device icon based on device type
|
||
|
|
const getDeviceIcon = (deviceType: string): "monitor" | "smartphone" | "tablet" => {
|
||
|
|
switch (deviceType.toLowerCase()) {
|
||
|
|
case "desktop":
|
||
|
|
return "monitor"
|
||
|
|
case "mobile":
|
||
|
|
return "smartphone"
|
||
|
|
case "tablet":
|
||
|
|
return "tablet"
|
||
|
|
default:
|
||
|
|
return "monitor"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Format last active time
|
||
|
|
const formatLastActive = (dateString: string): string => {
|
||
|
|
const date = new Date(dateString)
|
||
|
|
const now = new Date()
|
||
|
|
const diffMs = now.getTime() - date.getTime()
|
||
|
|
const diffMins = Math.floor(diffMs / 60000)
|
||
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
||
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
||
|
|
|
||
|
|
if (diffMins < 1) return "Just now"
|
||
|
|
if (diffMins < 60) return `${diffMins}m ago`
|
||
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
||
|
|
if (diffDays < 7) return `${diffDays}d ago`
|
||
|
|
return date.toLocaleDateString()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get other sessions (not current)
|
||
|
|
const otherSessions = sessions.filter((s) => !s.isCurrent)
|
||
|
|
const currentSession = sessions.find((s) => s.isCurrent)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
|
||
|
|
<ScrollView
|
||
|
|
contentContainerStyle={[$styles.container, themed($container)]}
|
||
|
|
showsVerticalScrollIndicator={false}
|
||
|
|
refreshControl={
|
||
|
|
<RefreshControl
|
||
|
|
refreshing={isRefreshing}
|
||
|
|
onRefresh={onRefresh}
|
||
|
|
tintColor={theme.colors.tint}
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<Text
|
||
|
|
size="xs"
|
||
|
|
tx="securityScreen:activeSessionsDescription"
|
||
|
|
style={themed($description)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{isLoading ? (
|
||
|
|
<SessionListSkeleton />
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{/* Current Session */}
|
||
|
|
{currentSession && (
|
||
|
|
<View style={[themed($sessionCard), themed($currentSessionCard)]}>
|
||
|
|
<View style={$sessionHeader}>
|
||
|
|
<View style={themed($deviceIconContainer)}>
|
||
|
|
<Icon
|
||
|
|
icon={getDeviceIcon(currentSession.deviceInfo.deviceType)}
|
||
|
|
size={20}
|
||
|
|
color={theme.colors.tint}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
<View style={$sessionInfo}>
|
||
|
|
<View style={$sessionTitleRow}>
|
||
|
|
<Text preset="bold" size="sm">
|
||
|
|
{currentSession.deviceInfo.deviceName ||
|
||
|
|
`${currentSession.deviceInfo.os} - ${currentSession.deviceInfo.browser}`}
|
||
|
|
</Text>
|
||
|
|
<View style={themed($currentBadge)}>
|
||
|
|
<Text size="xxs" style={themed($currentBadgeText)}>
|
||
|
|
{translate("securityScreen:currentDevice")}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
<Text size="xs" style={themed($sessionDetail)}>
|
||
|
|
{currentSession.location?.city
|
||
|
|
? `${currentSession.location.city} · ${currentSession.ipAddress}`
|
||
|
|
: currentSession.ipAddress}
|
||
|
|
</Text>
|
||
|
|
<Text size="xs" style={themed($sessionDetail)}>
|
||
|
|
{translate("securityScreen:lastActive")}:{" "}
|
||
|
|
{formatLastActive(currentSession.lastActiveAt)}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Other Sessions */}
|
||
|
|
{otherSessions.length > 0
|
||
|
|
? otherSessions.map((session) => (
|
||
|
|
<View key={session.id} style={themed($sessionCard)}>
|
||
|
|
<View style={$sessionHeader}>
|
||
|
|
<View style={themed($deviceIconContainerDim)}>
|
||
|
|
<Icon
|
||
|
|
icon={getDeviceIcon(session.deviceInfo.deviceType)}
|
||
|
|
size={20}
|
||
|
|
color={theme.colors.textDim}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
<View style={$sessionInfo}>
|
||
|
|
<Text preset="bold" size="sm">
|
||
|
|
{session.deviceInfo.deviceName ||
|
||
|
|
`${session.deviceInfo.os} - ${session.deviceInfo.browser}`}
|
||
|
|
</Text>
|
||
|
|
<Text size="xs" style={themed($sessionDetail)}>
|
||
|
|
{session.location?.city
|
||
|
|
? `${session.location.city} · ${session.ipAddress}`
|
||
|
|
: session.ipAddress}
|
||
|
|
</Text>
|
||
|
|
<Text size="xs" style={themed($sessionDetail)}>
|
||
|
|
{translate("securityScreen:lastActive")}:{" "}
|
||
|
|
{formatLastActive(session.lastActiveAt)}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
<TouchableOpacity
|
||
|
|
onPress={() => handleRevokeSession(session.id)}
|
||
|
|
disabled={revokingSessionId === session.id}
|
||
|
|
style={themed($logoutButton)}
|
||
|
|
>
|
||
|
|
{revokingSessionId === session.id ? (
|
||
|
|
<ActivityIndicator size="small" color={theme.colors.error} />
|
||
|
|
) : (
|
||
|
|
<Text size="xs" style={themed($logoutButtonText)}>
|
||
|
|
{translate("securityScreen:logoutDevice")}
|
||
|
|
</Text>
|
||
|
|
)}
|
||
|
|
</TouchableOpacity>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
))
|
||
|
|
: !currentSession && (
|
||
|
|
<View style={themed($emptyState)}>
|
||
|
|
<Text
|
||
|
|
size="sm"
|
||
|
|
tx="securityScreen:noOtherSessions"
|
||
|
|
style={themed($emptyStateText)}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</ScrollView>
|
||
|
|
|
||
|
|
{/* Fixed Bottom Button - Only show when there are other sessions */}
|
||
|
|
{otherSessions.length > 0 && (
|
||
|
|
<View style={themed($bottomContainer)}>
|
||
|
|
<Button
|
||
|
|
tx="securityScreen:logoutAllOther"
|
||
|
|
preset="reversed"
|
||
|
|
onPress={handleRevokeAllOther}
|
||
|
|
disabled={isRevokingAll}
|
||
|
|
loading={isRevokingAll}
|
||
|
|
/>
|
||
|
|
<Text
|
||
|
|
size="xxs"
|
||
|
|
tx="securityScreen:logoutAllOtherDescription"
|
||
|
|
style={themed($logoutAllDescription)}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Confirm Single Session Logout Dialog */}
|
||
|
|
<Dialog
|
||
|
|
visible={confirmDialogVisible}
|
||
|
|
onClose={() => setConfirmDialogVisible(false)}
|
||
|
|
preset="destructive"
|
||
|
|
titleTx="securityScreen:confirmLogout"
|
||
|
|
messageTx="securityScreen:confirmLogoutMessage"
|
||
|
|
confirmTx="securityScreen:logoutDevice"
|
||
|
|
onConfirm={confirmRevokeSession}
|
||
|
|
onCancel={() => setConfirmDialogVisible(false)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Confirm All Sessions Logout Dialog */}
|
||
|
|
<Dialog
|
||
|
|
visible={confirmAllDialogVisible}
|
||
|
|
onClose={() => setConfirmAllDialogVisible(false)}
|
||
|
|
preset="destructive"
|
||
|
|
titleTx="securityScreen:confirmLogout"
|
||
|
|
messageTx="securityScreen:confirmLogoutAllMessage"
|
||
|
|
confirmTx="securityScreen:logoutAllOther"
|
||
|
|
onConfirm={confirmRevokeAllOther}
|
||
|
|
onCancel={() => setConfirmAllDialogVisible(false)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Success Dialog */}
|
||
|
|
<Dialog
|
||
|
|
visible={successDialogVisible}
|
||
|
|
onClose={() => setSuccessDialogVisible(false)}
|
||
|
|
message={successMessage}
|
||
|
|
onConfirm={() => setSuccessDialogVisible(false)}
|
||
|
|
/>
|
||
|
|
</Screen>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingTop: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $description: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
|
|
color: colors.textDim,
|
||
|
|
marginBottom: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $sessionCard: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||
|
|
backgroundColor: colors.palette.neutral100,
|
||
|
|
borderRadius: s(12),
|
||
|
|
padding: spacing.md,
|
||
|
|
marginBottom: spacing.md,
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: colors.palette.neutral300,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $currentSessionCard: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
|
|
borderColor: colors.tint,
|
||
|
|
borderWidth: 1.5,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $deviceIconContainer: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
|
|
width: s(40),
|
||
|
|
height: s(40),
|
||
|
|
borderRadius: s(20),
|
||
|
|
backgroundColor: `${colors.tint}15`,
|
||
|
|
justifyContent: "center",
|
||
|
|
alignItems: "center",
|
||
|
|
})
|
||
|
|
|
||
|
|
const $deviceIconContainerDim: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
|
|
width: s(40),
|
||
|
|
height: s(40),
|
||
|
|
borderRadius: s(20),
|
||
|
|
backgroundColor: colors.palette.neutral200,
|
||
|
|
justifyContent: "center",
|
||
|
|
alignItems: "center",
|
||
|
|
})
|
||
|
|
|
||
|
|
const $sessionHeader: ViewStyle = {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
}
|
||
|
|
|
||
|
|
const $sessionInfo: ViewStyle = {
|
||
|
|
flex: 1,
|
||
|
|
marginLeft: s(14),
|
||
|
|
}
|
||
|
|
|
||
|
|
const $sessionTitleRow: ViewStyle = {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
flexWrap: "wrap",
|
||
|
|
gap: s(8),
|
||
|
|
}
|
||
|
|
|
||
|
|
const $currentBadge: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
|
|
backgroundColor: colors.tint,
|
||
|
|
paddingHorizontal: s(6),
|
||
|
|
paddingVertical: s(2),
|
||
|
|
borderRadius: s(4),
|
||
|
|
})
|
||
|
|
|
||
|
|
const $currentBadgeText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
|
|
color: colors.palette.neutral100,
|
||
|
|
fontWeight: "600",
|
||
|
|
})
|
||
|
|
|
||
|
|
const $sessionDetail: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
|
|
color: colors.textDim,
|
||
|
|
marginTop: s(2),
|
||
|
|
})
|
||
|
|
|
||
|
|
const $logoutButton: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||
|
|
paddingHorizontal: spacing.sm,
|
||
|
|
paddingVertical: spacing.xs,
|
||
|
|
borderRadius: s(4),
|
||
|
|
borderWidth: 1,
|
||
|
|
borderColor: colors.error,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $logoutButtonText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
|
|
color: colors.error,
|
||
|
|
fontWeight: "600",
|
||
|
|
})
|
||
|
|
|
||
|
|
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingHorizontal: spacing.lg,
|
||
|
|
paddingBottom: spacing.md,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $logoutAllDescription: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
|
|
color: colors.textDim,
|
||
|
|
textAlign: "center",
|
||
|
|
marginTop: spacing.xs,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $emptyState: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
padding: spacing.xl,
|
||
|
|
alignItems: "center",
|
||
|
|
})
|
||
|
|
|
||
|
|
const $emptyStateText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
|
|
color: colors.textDim,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Card height: padding (16*2) + icon (40) = 72, plus marginBottom (16) = 88
|
||
|
|
const SKELETON_CARD_HEIGHT = 88
|
||
|
|
// Header height + description + container padding approximation
|
||
|
|
const SKELETON_HEADER_OFFSET = 120
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Skeleton list that fills the screen with diagonal shimmer effect
|
||
|
|
*/
|
||
|
|
function SessionListSkeleton() {
|
||
|
|
const { themed, theme } = useAppTheme()
|
||
|
|
|
||
|
|
const skeletonCount = useMemo(() => {
|
||
|
|
const screenHeight = Dimensions.get("window").height
|
||
|
|
const availableHeight = screenHeight - SKELETON_HEADER_OFFSET
|
||
|
|
return Math.ceil(availableHeight / SKELETON_CARD_HEIGHT)
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<SkeletonContainer>
|
||
|
|
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||
|
|
<View key={index} style={themed($sessionCard)}>
|
||
|
|
<View style={$sessionHeader}>
|
||
|
|
{/* Device icon - 40x40 circle */}
|
||
|
|
<Skeleton width={40} height={40} radius="round" />
|
||
|
|
|
||
|
|
{/* Session info */}
|
||
|
|
<View style={$sessionInfo}>
|
||
|
|
{/* Device name - bold sm (16px) */}
|
||
|
|
<Skeleton width="65%" height={16} radius={4} />
|
||
|
|
|
||
|
|
{/* Location/IP - xs (14px), marginTop: 2 */}
|
||
|
|
<Skeleton
|
||
|
|
width="50%"
|
||
|
|
height={14}
|
||
|
|
radius={4}
|
||
|
|
style={{ marginTop: theme.spacing.xxs }}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Last active - xs (14px), marginTop: 2 */}
|
||
|
|
<Skeleton
|
||
|
|
width="45%"
|
||
|
|
height={14}
|
||
|
|
radius={4}
|
||
|
|
style={{ marginTop: theme.spacing.xxs }}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
))}
|
||
|
|
</SkeletonContainer>
|
||
|
|
)
|
||
|
|
}
|