diff --git a/apps/tlon-mobile/src/fixtures/GroupList.fixture.tsx b/apps/tlon-mobile/src/fixtures/GroupList.fixture.tsx index dace16dad6..8821915642 100644 --- a/apps/tlon-mobile/src/fixtures/GroupList.fixture.tsx +++ b/apps/tlon-mobile/src/fixtures/GroupList.fixture.tsx @@ -100,6 +100,8 @@ export default { basic: ( {}} pinned={[groupWithLongTitle, groupWithImage].map((g) => makeChannelSummary({ group: g }) )} @@ -116,6 +118,8 @@ export default { emptyPinned: ( {}} pinned={[dmSummary, groupDmSummary]} unpinned={[ groupWithColorAndNoImage, @@ -129,7 +133,13 @@ export default { ), loading: ( - + {}} + pinned={[]} + unpinned={[]} + pendingChats={[]} + /> ), }; diff --git a/apps/tlon-mobile/src/fixtures/ProfileWidget.fixture.tsx b/apps/tlon-mobile/src/fixtures/ProfileWidget.fixture.tsx index 87ab509823..293a2419f2 100644 --- a/apps/tlon-mobile/src/fixtures/ProfileWidget.fixture.tsx +++ b/apps/tlon-mobile/src/fixtures/ProfileWidget.fixture.tsx @@ -12,7 +12,6 @@ export default { > @@ -26,11 +25,7 @@ export default { verticalAlign="center" > - + ), @@ -44,7 +39,6 @@ export default { diff --git a/apps/tlon-mobile/src/hooks/useHandleLogout.ts b/apps/tlon-mobile/src/hooks/useHandleLogout.ts new file mode 100644 index 0000000000..6d757ac6c2 --- /dev/null +++ b/apps/tlon-mobile/src/hooks/useHandleLogout.ts @@ -0,0 +1,23 @@ +import * as api from '@tloncorp/shared/dist/api'; +import { useCallback } from 'react'; + +import { clearShipInfo, useShip } from '../contexts/ship'; +import { resetDb } from '../lib/nativeDb'; +import { removeHostingToken, removeHostingUserId } from '../utils/hosting'; + +export function useHandleLogout() { + const { clearShip } = useShip(); + + const handleLogout = useCallback(async () => { + api.queryClient.clear(); + api.removeUrbitClient(); + clearShip(); + clearShipInfo(); + removeHostingToken(); + removeHostingUserId(); + // delay DB reset to next tick to avoid race conditions + setTimeout(() => resetDb()); + }, [clearShip]); + + return handleLogout; +} diff --git a/apps/tlon-mobile/src/hooks/useLogout.ts b/apps/tlon-mobile/src/hooks/useLogout.ts deleted file mode 100644 index 1c9dba1b34..0000000000 --- a/apps/tlon-mobile/src/hooks/useLogout.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { removeUrbitClient } from '@tloncorp/shared/dist/api'; -import { useCallback } from 'react'; - -import { useShip } from '../contexts/ship'; -import { removeHostingToken, removeHostingUserId } from '../utils/hosting'; - -export function useLogout() { - const { clearShip } = useShip(); - const handleLogout = useCallback(() => { - clearShip(); - removeUrbitClient(); - removeHostingToken(); - removeHostingUserId(); - }, [clearShip]); - - return { handleLogout }; -} diff --git a/apps/tlon-mobile/src/hooks/useNotificationListener.ts b/apps/tlon-mobile/src/hooks/useNotificationListener.ts index 35748d8016..5902367a19 100644 --- a/apps/tlon-mobile/src/hooks/useNotificationListener.ts +++ b/apps/tlon-mobile/src/hooks/useNotificationListener.ts @@ -1,9 +1,11 @@ +import crashlytics from '@react-native-firebase/crashlytics'; import type { NavigationProp } from '@react-navigation/native'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { syncDms, syncGroups } from '@tloncorp/shared'; import { markChatRead } from '@tloncorp/shared/dist/api'; import * as api from '@tloncorp/shared/dist/api'; import * as db from '@tloncorp/shared/dist/db'; +import * as store from '@tloncorp/shared/dist/store'; import { whomIsDm, whomIsMultiDm } from '@tloncorp/shared/dist/urbit'; import { addNotificationResponseReceivedListener } from 'expo-notifications'; import { useEffect, useState } from 'react'; @@ -21,10 +23,12 @@ export default function useNotificationListener({ notificationChannelId, }: Props) { const navigation = useNavigation>(); - const [{ postId, channelId, isDm }, setGotoData] = useState<{ + const { data: isTlonEmployee } = store.useIsTlonEmployee(); + + const [{ postInfo, channelId, isDm }, setGotoData] = useState<{ path?: string; isDm?: boolean; - postId?: string | null; + postInfo?: { id: string; authorId: string } | null; channelId?: string; }>({ path: notificationPath, @@ -32,7 +36,7 @@ export default function useNotificationListener({ }); const resetGotoData = () => - setGotoData({ path: undefined, channelId: undefined }); + setGotoData({ path: undefined, channelId: undefined, postInfo: undefined }); // Start notifications prompt useEffect(() => { @@ -53,7 +57,7 @@ export default function useNotificationListener({ }, }, } = response; - const postId = api.getPostIdFromWer(data.wer); + const postInfo = api.getPostInfoFromWer(data.wer); const isDm = api.getIsDmFromWer(data.wer); if (actionIdentifier === 'markAsRead' && data.channelId) { markChatRead(data.channelId); @@ -63,7 +67,7 @@ export default function useNotificationListener({ setGotoData({ path: data.wer, isDm, - postId, + postInfo, channelId: data.channelId, }); } @@ -86,42 +90,33 @@ export default function useNotificationListener({ } // if we have a post id, try to navigate to the thread - if (postId) { - let postToNavigateTo: db.Post | null = null; - if (isDm) { - // for DMs, we get the backend ID (seal time) of the thread reply itself. So first we have to try to find that, - // then we grab the parent - const post = await db.getPostByBackendTime({ backendTime: postId }); - if (post && post.parentId) { - const parentPost = await db.getPost({ postId: post.parentId }); - if (parentPost) { - postToNavigateTo = parentPost; - } - } - } else { - // for group posts, we get the correct post ID and can just try to grab it - const post = await db.getPost({ postId }); - if (post) { - postToNavigateTo = post; - } - } + if (postInfo) { + let postToNavigateTo: { + id: string; + authorId: string; + channelId: string; + } | null = null; - // if we found the post, navigate to it. Otherwise fallback to channel - if (postToNavigateTo) { - navigation.dispatch( - CommonActions.reset({ - index: 1, - routes: [ - { name: 'ChatList' }, - { name: 'Channel', params: { channel } }, - { name: 'Post', params: { post: postToNavigateTo } }, - ], - }) - ); + const post = await db.getPost({ postId: postInfo.id }); - resetGotoData(); - return true; + if (post) { + postToNavigateTo = post; + } else { + postToNavigateTo = { ...postInfo, channelId }; } + + navigation.dispatch( + CommonActions.reset({ + index: 1, + routes: [ + { name: 'ChatList' }, + { name: 'Channel', params: { channel } }, + { name: 'Post', params: { post: postToNavigateTo } }, + ], + }) + ); + resetGotoData(); + return true; } navigation.navigate('Channel', { channel }); @@ -145,10 +140,19 @@ export default function useNotificationListener({ // If still not found, clear out the requested channel ID if (!didNavigate) { + if (isTlonEmployee) { + crashlytics().log(`failed channel ID: ${channelId}`); + crashlytics().log(`failed post ID: ${postInfo?.id}`); + } + crashlytics().recordError( + new Error( + `Notification listener: failed to navigate to ${isDm ? 'DM ' : ''}channel ${postInfo?.id ? ' thread' : ''}` + ) + ); resetGotoData(); } } })(); } - }, [channelId, postId, navigation, isDm]); + }, [channelId, postInfo, navigation, isDm]); } diff --git a/apps/tlon-mobile/src/lib/nativeDb.ts b/apps/tlon-mobile/src/lib/nativeDb.ts index 0bf46f7961..e7b3b8c6f5 100644 --- a/apps/tlon-mobile/src/lib/nativeDb.ts +++ b/apps/tlon-mobile/src/lib/nativeDb.ts @@ -99,3 +99,8 @@ async function runMigrations() { await migrate(client!, migrations); logger.log("migrations succeeded after purge, shouldn't happen often"); } + +export async function resetDb() { + await purgeDb(); + await migrate(client!, migrations); +} diff --git a/apps/tlon-mobile/src/navigation/RootStack.tsx b/apps/tlon-mobile/src/navigation/RootStack.tsx index 1c67e9a81a..227178ca7f 100644 --- a/apps/tlon-mobile/src/navigation/RootStack.tsx +++ b/apps/tlon-mobile/src/navigation/RootStack.tsx @@ -5,12 +5,19 @@ import { Platform, StatusBar } from 'react-native'; import { useIsDarkMode } from '../hooks/useIsDarkMode'; import { ActivityScreen } from '../screens/ActivityScreen'; +import { AppInfoScreen } from '../screens/AppInfo'; +import { AppSettingsScreen } from '../screens/AppSettingsScreen'; +import { BlockedUsersScreen } from '../screens/BlockedUsersScreen'; import ChannelScreen from '../screens/ChannelScreen'; import ChannelSearch from '../screens/ChannelSearchScreen'; import ChatListScreen from '../screens/ChatListScreen'; +import { EditProfileScreen } from '../screens/EditProfileScreen'; +import { FeatureFlagScreen } from '../screens/FeatureFlagScreen'; import { GroupChannelsScreen } from '../screens/GroupChannelsScreen'; import ImageViewerScreen from '../screens/ImageViewerScreen'; +import { ManageAccountScreen } from '../screens/ManageAccountScreen'; import PostScreen from '../screens/PostScreen'; +import { PushNotificationSettingsScreen } from '../screens/PushNotificationSettingsScreen'; import type { RootStackParamList } from '../types'; import { GroupSettingsStack } from './GroupSettingsStack'; import { SettingsStack } from './SettingsStack'; @@ -66,6 +73,17 @@ export function RootStack() { component={ImageViewerScreen} options={{ animation: 'fade' }} /> + + + + + + + + ); } diff --git a/apps/tlon-mobile/src/screens/AppInfo.tsx b/apps/tlon-mobile/src/screens/AppInfo.tsx new file mode 100644 index 0000000000..28e2749220 --- /dev/null +++ b/apps/tlon-mobile/src/screens/AppInfo.tsx @@ -0,0 +1,91 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import * as store from '@tloncorp/shared/dist/store'; +import { + AppSetting, + GenericHeader, + ListItem, + SizableText, + Stack, + View, + YStack, +} from '@tloncorp/ui'; +import { preSig } from '@urbit/aura'; +import * as Application from 'expo-application'; +import { useCallback } from 'react'; +import { Platform } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; + +import { NOTIFY_PROVIDER, NOTIFY_SERVICE } from '../constants'; +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +const BUILD_VERSION = `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${Application.nativeBuildVersion}`; + +export function AppInfoScreen(props: Props) { + const { data: appInfo } = store.useAppInfo(); + + const onPressPreviewFeatures = useCallback(() => { + props.navigation.navigate('FeatureFlags'); + }, [props.navigation]); + + return ( + + props.navigation.goBack()} + /> + + + + + + {appInfo ? ( + <> + + + + + ) : ( + + + Cannot load app info settings + + + )} + + + + + + Feature previews + + + + + + + + ); +} diff --git a/apps/tlon-mobile/src/screens/AppSettingsScreen.tsx b/apps/tlon-mobile/src/screens/AppSettingsScreen.tsx new file mode 100644 index 0000000000..2395606d65 --- /dev/null +++ b/apps/tlon-mobile/src/screens/AppSettingsScreen.tsx @@ -0,0 +1,101 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { GenericHeader, IconType, ListItem, View, YStack } from '@tloncorp/ui'; +import { useCallback, useEffect, useState } from 'react'; +import { ScrollView } from 'react-native-gesture-handler'; + +import { RootStackParamList } from '../types'; +import { getHostingToken, getHostingUserId } from '../utils/hosting'; + +type Props = NativeStackScreenProps; + +export function AppSettingsScreen(props: Props) { + const [hasHostedAuth, setHasHostedAuth] = useState(false); + + useEffect(() => { + async function getHostingInfo() { + const [cookie, userId] = await Promise.all([ + getHostingToken(), + getHostingUserId(), + ]); + if (cookie && userId) { + setHasHostedAuth(true); + } + } + getHostingInfo(); + }, []); + + const onManageAccountPressed = useCallback(() => { + props.navigation.navigate('ManageAccount'); + }, [props.navigation]); + + const onAppInfoPressed = useCallback(() => { + props.navigation.navigate('AppInfo'); + }, [props.navigation]); + + const onPushNotifPressed = useCallback(() => { + props.navigation.navigate('PushNotificationSettings'); + }, [props.navigation]); + + const onBlockedUsersPressed = useCallback(() => { + props.navigation.navigate('BlockedUsers'); + }, [props.navigation]); + + return ( + + props.navigation.goBack()} + /> + + + + + + {hasHostedAuth && ( + + )} + + + + ); +} + +function AppInfoListItem({ + onPress, + title, + icon, +}: { + onPress: () => void; + title: string; + icon: IconType; +}) { + return ( + + + + {title} + + + + + ); +} diff --git a/apps/tlon-mobile/src/screens/BlockedUsersScreen.tsx b/apps/tlon-mobile/src/screens/BlockedUsersScreen.tsx new file mode 100644 index 0000000000..5e8fc3ea46 --- /dev/null +++ b/apps/tlon-mobile/src/screens/BlockedUsersScreen.tsx @@ -0,0 +1,60 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import * as db from '@tloncorp/shared/dist/db'; +import * as store from '@tloncorp/shared/dist/store'; +import { + BlockedContactsWidget, + ContactsProvider, + ScreenHeader, + View, +} from '@tloncorp/ui'; +import { useCallback } from 'react'; +import { Alert } from 'react-native'; + +import { useCurrentUserId } from '../hooks/useCurrentUser'; +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function BlockedUsersScreen(props: Props) { + const currentUserId = useCurrentUserId(); + const { data: calm } = store.useCalmSettings({ userId: currentUserId }); + const { data: allContacts } = store.useContacts(); + const { data: blockedContacts } = store.useBlockedContacts(); + + const onBlockedContactPress = useCallback( + (contact: db.Contact) => { + Alert.alert( + `${calm?.disableNicknames && contact.nickname ? contact.nickname : contact.id}`, + `Are you sure you want to unblock this user?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Unblock', + onPress: () => store.unblockUser(contact.id), + }, + ] + ); + }, + [calm?.disableNicknames] + ); + + return ( + + + + props.navigation.goBack()} /> + Blocked Users + + + + + + + ); +} diff --git a/apps/tlon-mobile/src/screens/ChatListScreen.tsx b/apps/tlon-mobile/src/screens/ChatListScreen.tsx index 84ff1ccf22..af20ca58c3 100644 --- a/apps/tlon-mobile/src/screens/ChatListScreen.tsx +++ b/apps/tlon-mobile/src/screens/ChatListScreen.tsx @@ -48,6 +48,9 @@ export default function ChatListScreen( const [longPressedGroup, setLongPressedGroup] = useState( null ); + const [activeTab, setActiveTab] = useState<'all' | 'groups' | 'messages'>( + 'all' + ); const [selectedGroup, setSelectedGroup] = useState(null); const [startDmOpen, setStartDmOpen] = useState(false); const [addGroupOpen, setAddGroupOpen] = useState(false); @@ -239,9 +242,24 @@ export default function ChatListScreen( const { calmSettings } = useCalmSettings(); - const handleSectionChange = useCallback((title: string) => { - setScreenTitle(title); - }, []); + const handleSectionChange = useCallback( + (title: string) => { + if (activeTab === 'all') { + setScreenTitle(title); + } + }, + [activeTab] + ); + + useEffect(() => { + if (activeTab === 'all') { + setScreenTitle('Home'); + } else if (activeTab === 'groups') { + setScreenTitle('Groups'); + } else if (activeTab === 'messages') { + setScreenTitle('Messages'); + } + }, [activeTab]); const [splashVisible, setSplashVisible] = useState(true); @@ -274,6 +292,8 @@ export default function ChatListScreen( /> {chats && chats.unpinned.length ? ( ; + +export function EditProfileScreen(props: Props) { + const currentUserId = useCurrentUserId(); + const { data: contacts } = store.useContacts(); + const uploadInfo = useImageUpload({ + uploaderKey: 'profile-edit', + }); + + const onGoBack = useCallback(() => { + props.navigation.goBack(); + }, [props.navigation]); + + const onSaveProfile = useCallback( + (update: api.ProfileUpdate) => { + store.updateCurrentUserProfile(update); + props.navigation.goBack(); + }, + [props.navigation] + ); + + return ( + + + + + + ); +} diff --git a/apps/tlon-mobile/src/screens/FeatureFlagScreen.tsx b/apps/tlon-mobile/src/screens/FeatureFlagScreen.tsx index 9cd752a1e8..59975db07a 100644 --- a/apps/tlon-mobile/src/screens/FeatureFlagScreen.tsx +++ b/apps/tlon-mobile/src/screens/FeatureFlagScreen.tsx @@ -3,10 +3,10 @@ import { FeatureFlagScreenView } from '@tloncorp/ui'; import { useCallback, useState } from 'react'; import * as featureFlags from '../lib/featureFlags'; -import type { SettingsStackParamList } from '../types'; +import type { RootStackParamList } from '../types'; type FeatureFlagScreenProps = NativeStackScreenProps< - SettingsStackParamList, + RootStackParamList, 'FeatureFlags' >; diff --git a/apps/tlon-mobile/src/screens/ManageAccountScreen.tsx b/apps/tlon-mobile/src/screens/ManageAccountScreen.tsx new file mode 100644 index 0000000000..c33992570e --- /dev/null +++ b/apps/tlon-mobile/src/screens/ManageAccountScreen.tsx @@ -0,0 +1,94 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { LoadingSpinner, ScreenHeader, View, YStack } from '@tloncorp/ui'; +import { useCallback, useEffect, useState } from 'react'; +import { WebView } from 'react-native-webview'; + +import { useHandleLogout } from '../hooks/useHandleLogout'; +import { useWebView } from '../hooks/useWebView'; +import { getHostingUser } from '../lib/hostingApi'; +import { RootStackParamList } from '../types'; +import { getHostingToken, getHostingUserId } from '../utils/hosting'; + +const MANAGE_ACCOUNT_URL = 'https://tlon.network/account'; + +type Props = NativeStackScreenProps; + +interface HostingSession { + cookie: string; + userId: string; +} + +export function ManageAccountScreen(props: Props) { + const handleLogout = useHandleLogout(); + const webview = useWebView(); + const [hostingSession, setHostingSession] = useState( + null + ); + + useFocusEffect( + useCallback(() => { + // check if the user deleted their account when navigating away + return async () => { + const hostingUserId = await getHostingUserId(); + if (hostingUserId) { + try { + const user = await getHostingUser(hostingUserId); + if (!user.verified) { + handleLogout(); + } + } catch (err) { + handleLogout(); + } + } + }; + }, [handleLogout]) + ); + + useEffect(() => { + async function initialize() { + const [cookie, userId] = await Promise.all([ + getHostingToken(), + getHostingUserId(), + ]); + if (cookie && userId) { + // we need to strip HttpOnly from the cookie or it won't get sent along with the request + const modifiedCookie = cookie.replace(' HttpOnly;', ''); + setHostingSession({ cookie: modifiedCookie, userId }); + } else { + throw new Error( + 'Cannot manage account, failed to get hosting token or user ID.' + ); + } + } + initialize(); + }, []); + + return ( + + + props.navigation.goBack()} /> + Manage Account + + {hostingSession ? ( + + + + ) : ( + + + + )} + + ); +} diff --git a/apps/tlon-mobile/src/screens/ProfileScreen.tsx b/apps/tlon-mobile/src/screens/ProfileScreen.tsx index 2d6c37463a..544244f727 100644 --- a/apps/tlon-mobile/src/screens/ProfileScreen.tsx +++ b/apps/tlon-mobile/src/screens/ProfileScreen.tsx @@ -1,55 +1,36 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import * as api from '@tloncorp/shared/dist/api'; import * as store from '@tloncorp/shared/dist/store'; import { ProfileScreenView, View } from '@tloncorp/ui'; -import * as Application from 'expo-application'; import { useCallback } from 'react'; -import { Platform } from 'react-native'; -import { NOTIFY_PROVIDER, NOTIFY_SERVICE } from '../constants'; -import { clearShipInfo, useShip } from '../contexts/ship'; import { useCurrentUserId } from '../hooks/useCurrentUser'; -import { purgeDb } from '../lib/nativeDb'; +import { useHandleLogout } from '../hooks/useHandleLogout'; import NavBar from '../navigation/NavBarView'; -import { SettingsStackParamList } from '../types'; -import { removeHostingToken, removeHostingUserId } from '../utils/hosting'; +import { RootStackParamList } from '../types'; -type Props = NativeStackScreenProps; - -const DEBUG_MESSAGE = ` - Version: - ${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${Application.nativeBuildVersion} - - Notify Provider: - ${NOTIFY_PROVIDER} - - Notify Service: - ${NOTIFY_SERVICE} -`; +type Props = NativeStackScreenProps; export default function ProfileScreen(props: Props) { - const { clearShip } = useShip(); const currentUserId = useCurrentUserId(); const { data: contacts } = store.useContacts(); + const handleLogout = useHandleLogout(); + + const onAppSettingsPressed = useCallback(() => { + props.navigation.navigate('AppSettings'); + }, [props.navigation]); - const handleLogout = useCallback(async () => { - await purgeDb(); - api.queryClient.clear(); - api.removeUrbitClient(); - clearShip(); - clearShipInfo(); - removeHostingToken(); - removeHostingUserId(); - }, [clearShip]); + const onEditProfilePressed = useCallback(() => { + props.navigation.navigate('EditProfile'); + }, [props.navigation]); return ( props.navigation.navigate('FeatureFlags')} - handleLogout={handleLogout} + onAppSettingsPressed={onAppSettingsPressed} + onEditProfilePressed={onEditProfilePressed} + onLogoutPressed={handleLogout} /> diff --git a/apps/tlon-mobile/src/screens/PushNotificationSettingsScreen.tsx b/apps/tlon-mobile/src/screens/PushNotificationSettingsScreen.tsx new file mode 100644 index 0000000000..51c7796d3c --- /dev/null +++ b/apps/tlon-mobile/src/screens/PushNotificationSettingsScreen.tsx @@ -0,0 +1,98 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import * as store from '@tloncorp/shared/dist/store'; +import * as ub from '@tloncorp/shared/dist/urbit'; +import { + GenericHeader, + Icon, + SizableText, + View, + XStack, + YStack, +} from '@tloncorp/ui'; +import { useCallback } from 'react'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function PushNotificationSettingsScreen(props: Props) { + const { data: pushNotificationsSetting } = + store.usePushNotificationsSetting(); + + const setLevel = useCallback( + async (level: ub.PushNotificationsSetting) => { + if (level === pushNotificationsSetting) return; + await store.setDefaultNotificationLevel(level); + }, + [pushNotificationsSetting] + ); + + const LevelIndicator = useCallback( + (props: { level: ub.PushNotificationsSetting }) => { + if (pushNotificationsSetting === props.level) { + return ( + + + + ); + } + + return ( + + ); + }, + [pushNotificationsSetting] + ); + + return ( + + props.navigation.goBack()} + /> + + + Configure what kinds of messages will send you notifications. + + + + setLevel('all')}> + + All group activity + + + setLevel('some')}> + + + Mentions and replies only + + Direct messages will still notify unless you mute them. + + + + + setLevel('none')}> + + Nothing + + + + + ); +} diff --git a/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx b/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx index b4172c475a..4ed2bd4bf4 100644 --- a/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx +++ b/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx @@ -1,6 +1,6 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { getLandscapeAuthCookie } from '@tloncorp/shared/dist/api'; -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { Text, TextInput, View } from 'react-native'; import { useTailwind } from 'tailwind-rn'; @@ -46,13 +46,29 @@ export const ShipLoginScreen = ({ navigation }: Props) => { }); const { setShip } = useShip(); + const isValidUrl = useCallback((url: string) => { + const urlPattern = + /^(https?:\/\/)?(localhost|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|[\w.-]+\.([a-z]{2,}))(:\d+)?$/i; + const hostedPattern = /tlon\.network/i; + if (!urlPattern.test(url)) { + return false; + } + if (hostedPattern.test(url)) { + return 'hosted'; + } + return true; + }, []); + const onSubmit = handleSubmit(async ({ shipUrl: rawShipUrl, accessCode }) => { setIsSubmitting(true); const shipUrl = transformShipURL(rawShipUrl); setFormattedShipUrl(shipUrl); try { - const authCookie = await getLandscapeAuthCookie(shipUrl, accessCode); + const authCookie = await getLandscapeAuthCookie( + shipUrl, + accessCode.trim() + ); if (authCookie) { const shipId = getShipFromCookie(authCookie); if (await isEulaAgreed()) { @@ -65,7 +81,9 @@ export const ShipLoginScreen = ({ navigation }: Props) => { navigation.navigate('EULA', { shipId, shipUrl, authCookie }); } } else { - setRemoteError("Sorry, we couldn't log you into your Urbit ID."); + setRemoteError( + "Sorry, we couldn't log in to your ship. It may be busy or offline." + ); } } catch (err) { setRemoteError((err as Error).message); @@ -76,6 +94,9 @@ export const ShipLoginScreen = ({ navigation }: Props) => { useLayoutEffect(() => { navigation.setOptions({ + headerLeft: () => ( + navigation.goBack()} /> + ), headerRight: () => isSubmitting ? ( @@ -101,7 +122,7 @@ export const ShipLoginScreen = ({ navigation }: Props) => { 'text-lg font-medium text-tlon-black-80 dark:text-white' )} > - Connect an unhosted ship by entering its URL and access code. + Connect a self-hosted ship by entering its URL and access code. {remoteError ? ( {remoteError} @@ -118,13 +139,23 @@ export const ShipLoginScreen = ({ navigation }: Props) => { control={control} rules={{ required: 'Please enter a valid URL.', + validate: (value) => { + const urlValidation = isValidUrl(value); + if (urlValidation === false) { + return 'Please enter a valid URL.'; + } + if (urlValidation === 'hosted') { + return 'Please log in to your hosted Tlon ship using email and password.'; + } + return true; + }, }} render={({ field: { onChange, onBlur, value } }) => ( { navigation.setOptions({ + headerLeft: () => ( + navigation.goBack()} /> + ), headerRight: () => isSubmitting ? ( diff --git a/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx b/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx index fd67682387..03ae9f26cc 100644 --- a/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx +++ b/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx @@ -124,6 +124,9 @@ export const TlonLoginScreen = ({ navigation }: Props) => { useLayoutEffect(() => { navigation.setOptions({ + headerLeft: () => ( + navigation.goBack()} /> + ), headerRight: () => isSubmitting ? ( diff --git a/apps/tlon-mobile/src/types.ts b/apps/tlon-mobile/src/types.ts index fe5d9181ae..353cb0fb0f 100644 --- a/apps/tlon-mobile/src/types.ts +++ b/apps/tlon-mobile/src/types.ts @@ -21,7 +21,7 @@ export type WebViewStackParamList = { export type RootStackParamList = { ChatList: undefined; Activity: undefined; - Profile: NavigatorScreenParams; + Profile: undefined; Channel: { channel: db.Channel; selectedPostId?: string | null; @@ -33,7 +33,11 @@ export type RootStackParamList = { channel: db.Channel; }; Post: { - post: db.Post; + post: { + id: string; + channelId: string; + authorId: string; + }; }; ImageViewer: { post: db.Post; @@ -42,6 +46,13 @@ export type RootStackParamList = { GroupSettings: { group: db.Group; }; + AppSettings: undefined; + FeatureFlags: undefined; + ManageAccount: undefined; + BlockedUsers: undefined; + AppInfo: undefined; + PushNotificationSettings: undefined; + EditProfile: undefined; }; export type GroupSettingsStackParamList = { diff --git a/apps/tlon-mobile/src/utils/posthog.ts b/apps/tlon-mobile/src/utils/posthog.ts index a18c78c4f6..7dc269d26b 100644 --- a/apps/tlon-mobile/src/utils/posthog.ts +++ b/apps/tlon-mobile/src/utils/posthog.ts @@ -1,4 +1,5 @@ import crashlytics from '@react-native-firebase/crashlytics'; +import * as db from '@tloncorp/shared/dist/db'; import PostHog from 'posthog-react-native'; import { POST_HOG_API_KEY } from '../constants'; @@ -54,4 +55,5 @@ export const identifyTlonEmployee = () => { const UUID = posthog.getDistinctId(); posthog.identify(UUID, { isTlonEmployee: true }); + db.setIsTlonEmployee(true); }; diff --git a/apps/tlon-web/package.json b/apps/tlon-web/package.json index d597c8f914..3f94dcea9b 100644 --- a/apps/tlon-web/package.json +++ b/apps/tlon-web/package.json @@ -90,7 +90,7 @@ "@types/marked": "^4.3.0", "@urbit/api": "^2.2.0", "@urbit/aura": "^1.0.0", - "@urbit/http-api": "^3.1.0-dev-2", + "@urbit/http-api": "3.2.0-dev", "@urbit/sigil-js": "^2.2.0", "any-ascii": "^0.3.1", "big-integer": "^1.6.51", diff --git a/apps/tlon-web/src/api.ts b/apps/tlon-web/src/api.ts index e4a69c8099..ca64c731c4 100644 --- a/apps/tlon-web/src/api.ts +++ b/apps/tlon-web/src/api.ts @@ -299,11 +299,6 @@ class API { ...params, event: eventListener(params.event), quit: () => { - this.client!.subscribe({ - ...params, - event: eventListener(params.event), - }); - // should only happen once since we call this each invocation // and onReconnect will set the lastReconnect time const { lastReconnect, onReconnect } = useLocalState.getState(); diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 654f91ff85..0aaf6f0ae8 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v1.0i4g9.237vl.7ddqh.elnhk.njlbc.glob' 0v1.0i4g9.237vl.7ddqh.elnhk.njlbc] + glob-http+['https://bootstrap.urbit.org/glob-0v4.2d6l8.46eg8.cg9i7.jmtfb.qccv3.glob' 0v4.2d6l8.46eg8.cg9i7.jmtfb.qccv3] base+'groups' version+[6 1 0] website+'https://tlon.io' diff --git a/packages/shared/src/api/chatApi.ts b/packages/shared/src/api/chatApi.ts index 33f23e4605..928aa74bec 100644 --- a/packages/shared/src/api/chatApi.ts +++ b/packages/shared/src/api/chatApi.ts @@ -66,6 +66,8 @@ export const respondToDMInvite = ({ }; export type ChatEvent = + | { type: 'showPost'; postId: string } + | { type: 'hidePost'; postId: string } | { type: 'addDmInvites'; channels: db.Channel[] } | { type: 'groupDmsUpdate' } | { type: 'addPost'; post: db.Post; replyMeta?: db.ReplyMeta | null } @@ -84,6 +86,20 @@ export function subscribeToChatUpdates( (event: ub.WritResponse | ub.ClubAction | string[]) => { logger.log('raw chat sub event', event); + if ('show' in event) { + // show/unhide post event + logger.log('show/unhide post', event.show); + const postId = getCanonicalPostId(event.show as string); + return eventHandler({ type: 'showPost', postId }); + } + + if ('hide' in event) { + // hide post event + logger.log('hide post', event.hide); + const postId = getCanonicalPostId(event.hide as string); + return eventHandler({ type: 'hidePost', postId }); + } + // check for DM invites if (Array.isArray(event)) { // dm invites @@ -197,6 +213,10 @@ export function subscribeToChatUpdates( ); } +export function getBlockedUsers() { + return scry({ app: 'chat', path: '/blocked' }); +} + export function blockUser(userId: string) { return poke({ app: 'chat', @@ -248,6 +268,7 @@ export const toClientDm = (id: string, isInvite?: boolean): db.Channel => { title: '', description: '', isDmInvite: !!isInvite, + contactId: id, members: [{ chatId: id, contactId: id, membershipType: 'channel' }], }; }; diff --git a/packages/shared/src/api/contactsApi.ts b/packages/shared/src/api/contactsApi.ts index e3764c93e4..b5a602f6df 100644 --- a/packages/shared/src/api/contactsApi.ts +++ b/packages/shared/src/api/contactsApi.ts @@ -19,6 +19,41 @@ export const addContacts = async (contactIds: string[]) => { }); }; +export interface ProfileUpdate { + nickname?: string; + bio?: string; + avatarImage?: string; + coverImage?: string; +} +export const updateCurrentUserProfile = async (update: ProfileUpdate) => { + const editedFields = []; + if (update.nickname !== undefined) { + editedFields.push({ nickname: update.nickname }); + } + + if (update.bio !== undefined) { + editedFields.push({ bio: update.bio }); + } + + if (update.avatarImage !== undefined) { + editedFields.push({ avatar: update.avatarImage }); + } + + if (update.coverImage !== undefined) { + editedFields.push({ cover: update.coverImage }); + } + + const action: ub.ContactEdit = { + edit: editedFields, + }; + + return poke({ + app: 'contacts', + mark: 'contact-action', + json: action, + }); +}; + export type ContactsUpdate = | { type: 'add'; contact: db.Contact } | { type: 'delete'; contactId: string }; diff --git a/packages/shared/src/api/harkApi.ts b/packages/shared/src/api/harkApi.ts index 00e8354023..ab34b631a1 100644 --- a/packages/shared/src/api/harkApi.ts +++ b/packages/shared/src/api/harkApi.ts @@ -1,6 +1,8 @@ import { getCanonicalPostId } from './apiUtils'; -export const getPostIdFromWer = (wer: string): string | null => { +export const getPostInfoFromWer = ( + wer: string +): { id: string; authorId: string } | null => { const isDm = getIsDmFromWer(wer); const isChannelPost = getIsChannelPostFromWer(wer); const parts = wer.split('/'); @@ -10,12 +12,18 @@ export const getPostIdFromWer = (wer: string): string | null => { return null; } - if (isDm && parts[3]) { - return getCanonicalPostId(parts[3]); + if (isDm && parts[5]) { + return { + id: getCanonicalPostId(parts[5]), + authorId: parts[4], + }; } if (isChannelPost && isGroupChannelReply && parts[9]) { - return getCanonicalPostId(parts[9]); + return { + id: getCanonicalPostId(parts[9]), + authorId: '', + }; } return null; diff --git a/packages/shared/src/api/postsApi.ts b/packages/shared/src/api/postsApi.ts index 4a6bb56ca9..83344cb741 100644 --- a/packages/shared/src/api/postsApi.ts +++ b/packages/shared/src/api/postsApi.ts @@ -6,6 +6,8 @@ import * as ub from '../urbit'; import { ClubAction, DmAction, + HiddenMessages, + HiddenPosts, KindData, KindDataChat, Story, @@ -381,12 +383,14 @@ export const getLatestPosts = async ({ count ), }); + return response.map((head) => { const channelId = 'nest' in head ? head.nest : head.whom; + const latestPost = toPostData(channelId, head.latest); return { channelId: channelId, updatedAt: head.recency, - latestPost: toPostData(channelId, head.latest), + latestPost, }; }); }; @@ -572,34 +576,79 @@ export async function removeReaction({ }); } -export async function showPost(channelId: string, postId: string) { +export async function showPost(post: db.Post) { + if (isGroupChannelId(post.channelId)) { + const action = { + app: 'channels', + mark: 'channel-action', + json: { + 'toggle-post': { + show: post.id, + }, + }, + }; + + return poke(action); + } + + const writId = `${post.authorId}/${post.id}`; + const action = { - app: 'channels', - mark: 'channel-action', + app: 'chat', + mark: 'chat-toggle-message', json: { - 'toggle-post': { - show: postId, - }, + show: writId, }, }; - return await poke(action); + return poke(action); } -export async function hidePost(channelId: string, postId: string) { +export async function hidePost(post: db.Post) { + if (isGroupChannelId(post.channelId)) { + const action = { + app: 'channels', + mark: 'channel-action', + json: { + 'toggle-post': { + hide: post.id, + }, + }, + }; + + return poke(action); + } + + const writId = `${post.authorId}/${post.id}`; const action = { - app: 'channels', - mark: 'channel-action', + app: 'chat', + mark: 'chat-toggle-message', json: { - 'toggle-post': { - hide: postId, - }, + hide: writId, }, }; - return await poke(action); + return poke(action); } +export const getHiddenPosts = async () => { + const hiddenPosts = await scry({ + app: 'channels', + path: '/hidden-posts', + }); + + return hiddenPosts.map((postId) => getCanonicalPostId(postId)); +}; + +export const getHiddenDMPosts = async () => { + const hiddenDMPosts = await scry({ + app: 'chat', + path: '/hidden-messages', + }); + + return hiddenDMPosts.map((postId) => getCanonicalPostId(postId)); +}; + export async function deletePost(channelId: string, postId: string) { const action = channelAction(channelId, { post: { @@ -687,35 +736,36 @@ export function toPagedPostsData( data: ub.PagedPosts | ub.PagedWrits ): GetChannelPostsResponse { const posts = 'writs' in data ? data.writs : data.posts; + const postsData = toPostsData(channelId, posts); return { older: data.older ? formatUd(data.older) : null, newer: data.newer ? formatUd(data.newer) : null, totalPosts: data.total, - ...toPostsData(channelId, posts), + ...postsData, }; } export function toPostsData( channelId: string, posts: ub.Posts | ub.Writs | Record -) { - const [deletedPosts, otherPosts] = Object.entries(posts).reduce< - [string[], db.Post[]] - >( - (memo, [id, post]) => { - if (post === null) { - memo[0].push(id); - } else { - memo[1].push(toPostData(channelId, post)); - } - return memo; - }, - [[], []] - ); +): { posts: db.Post[]; deletedPosts: string[] } { + const entries = Object.entries(posts); + const deletedPosts: string[] = []; + const otherPosts: db.Post[] = []; + + for (const [id, post] of entries) { + if (post === null) { + deletedPosts.push(id); + } else { + const postData = toPostData(channelId, post); + otherPosts.push(postData); + } + } + + otherPosts.sort((a, b) => (a.receivedAt ?? 0) - (b.receivedAt ?? 0)); + return { - posts: otherPosts.sort((a, b) => { - return (a.receivedAt ?? 0) - (b.receivedAt ?? 0); - }), + posts: otherPosts, deletedPosts, }; } @@ -754,6 +804,10 @@ export function toPostData( ? getCanonicalPostId(post.seal.time.toString()) : null; + const replyData = isPostDataResponse(post) + ? getReplyData(id, channelId, post) + : null; + return { id, channelId, @@ -773,9 +827,7 @@ export function toPostData( replyContactIds: post?.seal.meta.lastRepliers, images: getContentImages(id, post.essay?.content), reactions: toReactionsData(post?.seal.reacts ?? {}, id), - replies: isPostDataResponse(post) - ? getReplyData(id, channelId, post) - : null, + replies: replyData, deliveryStatus: null, syncedAt: Date.now(), ...flags, diff --git a/packages/shared/src/api/settingsApi.ts b/packages/shared/src/api/settingsApi.ts index 164e61728c..c9c18fbbde 100644 --- a/packages/shared/src/api/settingsApi.ts +++ b/packages/shared/src/api/settingsApi.ts @@ -1,3 +1,5 @@ +import { ChargeUpdateInitial, Pikes, getPikes, scryCharges } from '@urbit/api'; + import * as db from '../db'; import * as ub from '../urbit'; import { client, scry } from './urbit'; @@ -57,3 +59,17 @@ export const toClientSettings = ( notebookSettings: JSON.stringify(settings.desk.diary), }; }; + +export async function getAppInfo(): Promise { + const pikes = await scry(getPikes); + const charges = (await scry(scryCharges)).initial; + + const groupsPike = pikes?.['groups'] ?? {}; + const groupsCharge = charges?.['groups'] ?? {}; + + return { + groupsVersion: groupsCharge.version ?? 'n/a', + groupsHash: groupsPike.hash ?? 'n/a', + groupsSyncNode: groupsPike.sync?.ship ?? 'n/a', + }; +} diff --git a/packages/shared/src/api/urbit.ts b/packages/shared/src/api/urbit.ts index a757c369e0..d05f0c8766 100644 --- a/packages/shared/src/api/urbit.ts +++ b/packages/shared/src/api/urbit.ts @@ -44,6 +44,14 @@ export const getCurrentUserId = () => { return client.our; }; +export const getCurrentUserIsHosted = () => { + if (!client.our) { + throw new Error('Client not initialized'); + } + + return client.url.endsWith('tlon.network'); +}; + export function configureClient({ shipName, shipUrl, diff --git a/packages/shared/src/db/keyValue.ts b/packages/shared/src/db/keyValue.ts index 78d11cbc62..1d317b4796 100644 --- a/packages/shared/src/db/keyValue.ts +++ b/packages/shared/src/db/keyValue.ts @@ -16,6 +16,9 @@ export const PUSH_NOTIFICATIONS_SETTING_QUERY_KEY = [ 'pushNotifications', ]; +export const IS_TLON_EMPLOYEE_QUERY_KEY = ['settings', 'isTlonEmployee']; +export const APP_INFO_QUERY_KEY = ['settings', 'appInfo']; + export type ChannelSortPreference = 'recency' | 'arranged'; export async function storeChannelSortPreference( sortPreference: ChannelSortPreference @@ -63,3 +66,31 @@ export async function getPushNotificationsSetting(): Promise { + const storedAppInfo = await AsyncStorage.getItem(`settings:appInfo`); + const appInfo = storedAppInfo ? (JSON.parse(storedAppInfo) as AppInfo) : null; + return appInfo; +} diff --git a/packages/shared/src/db/migrations/0000_thankful_thing.sql b/packages/shared/src/db/migrations/0000_furry_king_cobra.sql similarity index 99% rename from packages/shared/src/db/migrations/0000_thankful_thing.sql rename to packages/shared/src/db/migrations/0000_furry_king_cobra.sql index d27076f354..bb707399cc 100644 --- a/packages/shared/src/db/migrations/0000_thankful_thing.sql +++ b/packages/shared/src/db/migrations/0000_furry_king_cobra.sql @@ -49,6 +49,7 @@ CREATE TABLE `channels` ( `cover_image_color` text, `title` text, `description` text, + `contact_id` text, `added_to_group_at` integer, `current_user_is_member` integer, `post_count` integer, diff --git a/packages/shared/src/db/migrations/meta/0000_snapshot.json b/packages/shared/src/db/migrations/meta/0000_snapshot.json index 57a006dfae..6ae8cfc902 100644 --- a/packages/shared/src/db/migrations/meta/0000_snapshot.json +++ b/packages/shared/src/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "c25041ca-efef-491c-9869-b3763f16f213", + "id": "5fa6c36d-ce08-4b92-ae6d-305c6fbc307b", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity_events": { @@ -312,6 +312,13 @@ "notNull": false, "autoincrement": false }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "added_to_group_at": { "name": "added_to_group_at", "type": "integer", diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index 8338004b81..11c2302214 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1720021644437, - "tag": "0000_thankful_thing", + "when": 1720805158211, + "tag": "0000_furry_king_cobra", "breakpoints": true } ] diff --git a/packages/shared/src/db/migrations/migrations.js b/packages/shared/src/db/migrations/migrations.js index 935ababcbc..55bd039368 100644 --- a/packages/shared/src/db/migrations/migrations.js +++ b/packages/shared/src/db/migrations/migrations.js @@ -1,7 +1,7 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import journal from './meta/_journal.json'; -import m0000 from './0000_thankful_thing.sql'; +import m0000 from './0000_furry_king_cobra.sql'; export default { journal, diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 4195342187..19d2fc4f01 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -1806,11 +1806,11 @@ async function insertPosts(posts: Post[], ctx: QueryCtx) { ) .onConflictDoUpdate({ target: $posts.id, - set: conflictUpdateSetAll($posts), + set: conflictUpdateSetAll($posts, ['hidden']), }) .onConflictDoUpdate({ target: [$posts.authorId, $posts.sentAt], - set: conflictUpdateSetAll($posts), + set: conflictUpdateSetAll($posts, ['hidden']), }); logger.log('inserted posts'); await setLastPosts(posts, ctx); @@ -1822,6 +1822,31 @@ async function insertPosts(posts: Post[], ctx: QueryCtx) { logger.log('clear matched pending'); } +export const insertHiddenPosts = createWriteQuery( + 'insertHiddenPosts', + async (postIds: string[], ctx: QueryCtx) => { + if (postIds.length === 0) return; + + logger.log('insertHiddenPosts', postIds); + + await ctx.db + .update($posts) + .set({ hidden: true }) + .where(inArray($posts.id, postIds)); + }, + ['posts'] +); + +export const getHiddenPosts = createReadQuery( + 'getHiddenPosts', + async (ctx: QueryCtx) => { + return ctx.db.query.posts.findMany({ + where: eq($posts.hidden, true), + }); + }, + ['posts'] +); + async function setLastPosts(newPosts: Post[] | null, ctx: QueryCtx) { const channelIds = newPosts?.map((p) => p.channelId) ?? []; @@ -2270,6 +2295,37 @@ export const getGroupByChannel = createReadQuery( ['channels', 'groups'] ); +export const insertBlockedContacts = createWriteQuery( + 'insertBlockedContacts', + async ({ blockedIds }: { blockedIds: string[] }, ctx: QueryCtx) => { + if (blockedIds.length === 0) return; + + const blockedContacts: Contact[] = blockedIds.map((id) => ({ + id, + isBlocked: true, + })); + + return ctx.db + .insert($contacts) + .values(blockedContacts) + .onConflictDoUpdate({ + target: $contacts.id, + set: conflictUpdateSet($contacts.isBlocked), + }); + }, + ['contacts'] +); + +export const getBlockedUsers = createReadQuery( + 'getBlockedUsers', + async (ctx: QueryCtx) => { + return ctx.db.query.contacts.findMany({ + where: eq($contacts.isBlocked, true), + }); + }, + ['contacts'] +); + export const getContacts = createReadQuery( 'getContacts', async (ctx: QueryCtx) => { @@ -2929,16 +2985,24 @@ function conflictUpdateSetAll(table: Table, exclude?: string[]) { function conflictUpdateSet(...columns: Column[]) { return Object.fromEntries( - columns.map((c) => [toCamelCase(c.name), sql.raw(`excluded.${c.name}`)]) + columns.map((c) => { + return [getColumnTsName(c), sql.raw(`excluded.${c.name}`)]; + }) ); } -function ascNullsLast(column: SQLWrapper | AnyColumn) { - return sql`${column} ASC NULLS LAST`; +function getColumnTsName(c: Column) { + const name = Object.keys(c.table).find( + (k) => c.table[k as keyof typeof c.table] === c + ); + if (!name) { + throw new Error('unable to find column name'); + } + return name; } -function toCamelCase(str: string) { - return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); +function ascNullsLast(column: SQLWrapper | AnyColumn) { + return sql`${column} ASC NULLS LAST`; } function returnNullIfUndefined(input: T | undefined): T | null { diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index cff0a0a805..1e830d4e1a 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -628,6 +628,7 @@ export const channels = sqliteTable( onDelete: 'cascade', }), ...metaFields, + contactId: text('contact_id'), addedToGroupAt: timestamp('added_to_group_at'), currentUserIsMember: boolean('current_user_is_member'), postCount: integer('post_count'), @@ -660,6 +661,10 @@ export const channelRelations = relations(channels, ({ one, many }) => ({ fields: [channels.groupId], references: [groups.id], }), + contact: one(contacts, { + fields: [channels.contactId], + references: [contacts.id], + }), posts: many(posts), lastPost: one(posts, { fields: [channels.lastPostId], diff --git a/packages/shared/src/logic/utils.ts b/packages/shared/src/logic/utils.ts index be3dcf8704..38a75e6a0f 100644 --- a/packages/shared/src/logic/utils.ts +++ b/packages/shared/src/logic/utils.ts @@ -4,16 +4,23 @@ import { differenceInDays, endOfToday, format, + getDate, } from 'date-fns'; import emojiRegex from 'emoji-regex'; import { backOff } from 'exponential-backoff'; import { useMemo } from 'react'; import * as api from '../api'; -import { isDmChannelId, isGroupDmChannelId } from '../api/apiUtils'; +import { + isDmChannelId, + isGroupChannelId, + isGroupDmChannelId, +} from '../api/apiUtils'; import * as db from '../db'; import * as ub from '../urbit'; +export { isDmChannelId, isGroupDmChannelId, isGroupChannelId }; + export const IMAGE_REGEX = /(\.jpg|\.img|\.png|\.gif|\.tiff|\.jpeg|\.webp|\.svg)(?:\?.*)?$/i; export const AUDIO_REGEX = /(\.mp3|\.wav|\.ogg|\.m4a)(?:\?.*)?$/i; @@ -98,7 +105,7 @@ export function makePrettyDay(date: Date) { } export function makePrettyShortDate(date: Date) { - return format(date, 'MMM dd, yyyy'); + return format(date, `MMMM do, yyyy`); } export function makeShortDate(date: Date) { @@ -363,6 +370,33 @@ export const textPostIsLinkedImage = (post: db.Post): boolean => { return false; }; +export const textPostIsLink = (post: db.Post): boolean => { + const postIsJustText = isTextPost(post); + if (!postIsJustText) { + return false; + } + + const postIsImage = textPostIsLinkedImage(post); + if (postIsImage) { + return false; + } + + const { inlines } = extractContentTypesFromPost(post); + + if (inlines.length <= 2) { + const [first] = inlines; + if (typeof first === 'object' && 'link' in first) { + const link = first as ub.Link; + const { href } = link.link; + const isLink = URL_REGEX.test(href); + + return isLink; + } + } + + return false; +}; + export const textPostIsReference = (post: db.Post): boolean => { const { inlines, references } = extractContentTypesFromPost(post); if (references.length === 0) { @@ -413,6 +447,7 @@ export const usePostMeta = (post: db.Post) => { ); const isText = useMemo(() => isTextPost(post), [post]); const isImage = useMemo(() => isImagePost(post), [post]); + const isLink = useMemo(() => textPostIsLink(post), [post]); const isReference = useMemo(() => isReferencePost(post), [post]); const isLinkedImage = useMemo(() => textPostIsLinkedImage(post), [post]); const isRefInText = useMemo(() => textPostIsReference(post), [post]); @@ -428,6 +463,7 @@ export const usePostMeta = (post: db.Post) => { return { isText, isImage, + isLink, isReference, isLinkedImage, isRefInText, diff --git a/packages/shared/src/store/contactActions.ts b/packages/shared/src/store/contactActions.ts new file mode 100644 index 0000000000..70ee9c5310 --- /dev/null +++ b/packages/shared/src/store/contactActions.ts @@ -0,0 +1,27 @@ +import * as api from '../api'; +import * as db from '../db'; + +export async function updateCurrentUserProfile(update: api.ProfileUpdate) { + const currentUserId = api.getCurrentUserId(); + const currentUserContact = await db.getContact({ id: currentUserId }); + const startingValues: Partial = {}; + if (currentUserContact) { + for (const key in update) { + if (key in currentUserContact) { + startingValues[key as keyof api.ProfileUpdate] = + currentUserContact[key as keyof api.ProfileUpdate]; + } + } + } + + // Optimistic update + await db.updateContact({ id: currentUserId, ...update }); + + try { + await api.updateCurrentUserProfile(update); + } catch (e) { + console.error('Error updating profile', e); + // Rollback the update + await db.updateContact({ id: currentUserId, ...startingValues }); + } +} diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index 5d31a40d31..fcd921ae06 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -70,6 +70,13 @@ export const useCalmSettings = (options: { userId: string }) => { }); }; +export const useAppInfo = () => { + return useQuery({ + queryKey: db.APP_INFO_QUERY_KEY, + queryFn: db.getAppInfoSettings, + }); +}; + export const useActivitySeenMarker = () => { return useQuery({ queryKey: db.ACTIVITY_SEEN_MARKER_QUERY_KEY, @@ -84,6 +91,13 @@ export const usePushNotificationsSetting = () => { }); }; +export const useIsTlonEmployee = () => { + return useQuery({ + queryKey: db.IS_TLON_EMPLOYEE_QUERY_KEY, + queryFn: db.getIsTlonEmployee, + }); +}; + export const useContact = (options: { id: string }) => { const deps = useKeyFromQueryDeps(db.getContact); return useQuery({ @@ -92,6 +106,14 @@ export const useContact = (options: { id: string }) => { }); }; +export const useBlockedContacts = () => { + const depsKey = useKeyFromQueryDeps(db.getBlockedUsers); + return useQuery({ + queryKey: ['blockedContacts', depsKey], + queryFn: () => db.getBlockedUsers(), + }); +}; + export const useContacts = () => { const deps = useKeyFromQueryDeps(db.getContacts); return useQuery({ diff --git a/packages/shared/src/store/dmActions.ts b/packages/shared/src/store/dmActions.ts index d3ffe783d3..2ae0be717d 100644 --- a/packages/shared/src/store/dmActions.ts +++ b/packages/shared/src/store/dmActions.ts @@ -60,3 +60,25 @@ export async function blockUser(userId: string) { } } } + +export async function unblockUser(userId: string) { + logger.log(`unblocking user`, userId); + // optimistic update + const existingContact = await db.getContact({ id: userId }); + if (existingContact) { + await db.updateContact({ id: userId, isBlocked: false }); + } + + try { + await api.unblockUser(userId); + } catch (e) { + console.error('Failed to unblock user', e); + // rollback optimistic update + if (existingContact) { + await db.updateContact({ + id: userId, + isBlocked: existingContact.isBlocked, + }); + } + } +} diff --git a/packages/shared/src/store/index.ts b/packages/shared/src/store/index.ts index 7902a3ee15..0b52bf99f6 100644 --- a/packages/shared/src/store/index.ts +++ b/packages/shared/src/store/index.ts @@ -13,3 +13,4 @@ export * from './useNegotiation'; export * from './activityActions'; export * from './useActivityFetchers'; export * from './session'; +export * from './contactActions'; diff --git a/packages/shared/src/store/postActions.ts b/packages/shared/src/store/postActions.ts index 1202c0ef04..6bb8b76de1 100644 --- a/packages/shared/src/store/postActions.ts +++ b/packages/shared/src/store/postActions.ts @@ -131,7 +131,7 @@ export async function hidePost({ post }: { post: db.Post }) { await db.updatePost({ id: post.id, hidden: true }); try { - await api.hidePost(post.channelId, post.id); + await api.hidePost(post); } catch (e) { console.error('Failed to hide post', e); @@ -145,7 +145,7 @@ export async function showPost({ post }: { post: db.Post }) { await db.updatePost({ id: post.id, hidden: false }); try { - await api.showPost(post.channelId, post.id); + await api.showPost(post); } catch (e) { console.error('Failed to show post', e); diff --git a/packages/shared/src/store/sync.test.ts b/packages/shared/src/store/sync.test.ts index 2d9f4ac279..8d0b526e7f 100644 --- a/packages/shared/src/store/sync.test.ts +++ b/packages/shared/src/store/sync.test.ts @@ -138,6 +138,7 @@ test('syncs dms', async () => { id: '~solfer-magfed', type: 'dm', groupId: null, + contactId: '~solfer-magfed', iconImage: null, iconImageColor: null, coverImage: null, @@ -174,6 +175,7 @@ test('syncs dms', async () => { id: '0v4.00000.qd4p2.it253.qs53q.s53qs', type: 'groupDm', groupId: null, + contactId: null, iconImage: null, iconImageColor: '#f0ebbd', coverImage: null, diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts index d0abb1c74e..d376c8faaa 100644 --- a/packages/shared/src/store/sync.ts +++ b/packages/shared/src/store/sync.ts @@ -117,6 +117,11 @@ function checkForNewlyJoined({ } } +export const syncBlockedUsers = async () => { + const blockedIds = await api.getBlockedUsers(); + await db.insertBlockedContacts({ blockedIds }); +}; + export const syncChannelHeads = async ( reporter?: ErrorReporter, priority = SyncPriority.High @@ -142,6 +147,13 @@ export const syncSettings = async (priority = SyncPriority.Medium) => { return db.insertSettings(settings); }; +export const syncAppInfo = async (priority = SyncPriority.Medium) => { + const appInfo = await syncQueue.add('appInfo', priority, () => + api.getAppInfo() + ); + return db.setAppInfoSettings(appInfo); +}; + export const syncVolumeSettings = async (priority = SyncPriority.Medium) => { const volumeSettings = await syncQueue.add('volumeSettings', priority, () => api.getVolumeSettings() @@ -604,6 +616,12 @@ export const handleChatUpdate = async (update: api.ChatEvent) => { logger.log('event: chat update', update); switch (update.type) { + case 'showPost': + await db.updatePost({ id: update.postId, hidden: false }); + break; + case 'hidePost': + await db.updatePost({ id: update.postId, hidden: true }); + break; case 'addPost': await handleAddPost(update.post, update.replyMeta); break; @@ -681,6 +699,38 @@ export async function handleAddPost( } } +export async function syncHiddenPosts(reporter: ErrorReporter) { + const hiddenPosts = await syncQueue.add( + 'hiddenPosts', + SyncPriority.High, + () => api.getHiddenPosts() + ); + reporter?.log('got hidden channel posts data from api'); + const hiddenDMPosts = await syncQueue.add( + 'hiddenDMPosts', + SyncPriority.High, + () => api.getHiddenDMPosts() + ); + reporter?.log('got hidden dm posts data from api'); + + const currentHiddenPosts = await db.getHiddenPosts(); + + // if the user deleted the posts from another client while we were offline, + // we should remove them from our hidden posts list + currentHiddenPosts.forEach(async (hiddenPost) => { + if ( + !hiddenPosts.some((postId) => postId === hiddenPost.id) && + !hiddenDMPosts.some((postId) => postId === hiddenPost.id) + ) { + reporter?.log(`deleting hidden post ${hiddenPost.id}`); + await db.updatePost({ id: hiddenPost.id, hidden: false }); + } + }); + + await db.insertHiddenPosts([...hiddenPosts, ...hiddenDMPosts]); + reporter?.log('inserted hidden posts'); +} + export async function syncPosts( options: api.GetChannelPostsOptions, priority = SyncPriority.Medium @@ -706,6 +756,7 @@ export async function syncPosts( syncedAt: Date.now(), }); } + return response; } @@ -797,6 +848,8 @@ export const syncStart = async (alreadySubscribed?: boolean) => { // highest priority, do immediately await withRetry(() => syncInitData(reporter)); reporter.log(`finished syncing init data`); + await withRetry(() => syncHiddenPosts(reporter)); + reporter.log(`finished syncing hidden posts`); await withRetry(() => syncChannelHeads(reporter)); reporter.log(`finished syncing latest posts`); @@ -830,6 +883,12 @@ export const syncStart = async (alreadySubscribed?: boolean) => { syncPushNotificationsSetting().then(() => reporter.log(`finished syncing push notifications setting`) ), + syncBlockedUsers().then(() => { + reporter.log(`finished syncing blocked users`); + }), + syncAppInfo().then(() => { + reporter.log(`finished syncing app info`); + }), ]) ); diff --git a/packages/ui/package.json b/packages/ui/package.json index 926092c8b7..1c74487791 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,6 +37,7 @@ "expo-blur": "^12.9.2", "expo-haptics": "^12.8.1", "expo-image-picker": "~14.7.1", + "fuzzy": "^0.1.3", "lodash": "^4.17.21", "moti": "^0.28.1", "react-hook-form": "^7.52.0", diff --git a/packages/ui/src/assets/icons/Copy.svg b/packages/ui/src/assets/icons/Copy.svg new file mode 100644 index 0000000000..b96f8682cb --- /dev/null +++ b/packages/ui/src/assets/icons/Copy.svg @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index 950eda1e85..aae1642b7c 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -43,6 +43,7 @@ export { default as ChevronRight } from './ChevronRight.svg'; export { default as ChevronUp } from './ChevronUp.svg'; export { default as Clock } from './Clock.svg'; export { default as Close } from './Close.svg'; +export { default as Copy } from './Copy.svg'; export { default as Discover } from './Discover.svg'; export { default as Dragger } from './Dragger.svg'; export { default as Draw } from './Draw.svg'; diff --git a/packages/ui/src/components/Activity/ActivityHeader.tsx b/packages/ui/src/components/Activity/ActivityHeader.tsx index 4165b0f7b3..be35d1f507 100644 --- a/packages/ui/src/components/Activity/ActivityHeader.tsx +++ b/packages/ui/src/components/Activity/ActivityHeader.tsx @@ -1,9 +1,9 @@ import * as db from '@tloncorp/shared/dist/db'; import React from 'react'; -import { SizableText, View, XStack } from '../../core'; -import Pressable from '../Pressable'; +import { View } from '../../core'; import { ScreenHeader } from '../ScreenHeader'; +import { Tabs } from '../Tabs'; export type ActivityTab = 'all' | 'threads' | 'mentions'; @@ -21,72 +21,29 @@ function ActivityHeaderRaw({ Activity - - + onTabPress('all')} + name="all" > - onTabPress('all')} - > - - All - - - - All + + onTabPress('mentions')} + name="mentions" > - onTabPress('mentions')} - > - - Mentions - - - - Mentions + + onTabPress('replies')} + name="replies" > - onTabPress('replies')} - > - - Replies - - - - + Replies + + ); } diff --git a/packages/ui/src/components/Activity/ActivitySummaryMessage.tsx b/packages/ui/src/components/Activity/ActivitySummaryMessage.tsx index 6138ec43a3..1236cc2098 100644 --- a/packages/ui/src/components/Activity/ActivitySummaryMessage.tsx +++ b/packages/ui/src/components/Activity/ActivitySummaryMessage.tsx @@ -149,7 +149,7 @@ function SummaryMessageRaw({ {` ${postVerb(newest.channel?.type ?? 'chat')} ${count} ${postName(newest, count > 1)}`} diff --git a/packages/ui/src/components/AddChats/CreateGroupWidget.tsx b/packages/ui/src/components/AddChats/CreateGroupWidget.tsx index cf6fed617a..a47abb0af8 100644 --- a/packages/ui/src/components/AddChats/CreateGroupWidget.tsx +++ b/packages/ui/src/components/AddChats/CreateGroupWidget.tsx @@ -50,7 +50,7 @@ export function CreateGroupWidget(props: { props.goBack()} /> - Start a New Group + Start a New Group What is your group about? diff --git a/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx b/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx index 430dc4b64e..f160e8ad02 100644 --- a/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx +++ b/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx @@ -49,7 +49,7 @@ export function ViewUserGroupsWidget({ Loading groups hosted by{' '} - + ) : isError ? ( @@ -59,7 +59,7 @@ export function ViewUserGroupsWidget({ @@ -69,7 +69,7 @@ export function ViewUserGroupsWidget({ Groups hosted by{' '} - : + : { + if (didCopy) return; + Clipboard.setString(value); + setDidCopy(true); + setTimeout(() => { + setDidCopy(false); + }, 3000); + }, [didCopy, value]); + + return ( + + + {title} + {value} + + {copyable && ( + + + + )} + + ); +} diff --git a/packages/ui/src/components/AuthorRow.tsx b/packages/ui/src/components/AuthorRow.tsx index 793a5d1a4b..44b54e230a 100644 --- a/packages/ui/src/components/AuthorRow.tsx +++ b/packages/ui/src/components/AuthorRow.tsx @@ -89,7 +89,7 @@ function ChatAuthorRow({ authorId, sent, roles, ...props }: AuthorRowProps) { return ( - + {timeDisplay} diff --git a/packages/ui/src/components/Avatar.tsx b/packages/ui/src/components/Avatar.tsx index b620202def..9c81e074a4 100644 --- a/packages/ui/src/components/Avatar.tsx +++ b/packages/ui/src/components/Avatar.tsx @@ -50,6 +50,11 @@ const AvatarFrame = styled(View, { width: '$5xl', borderRadius: '$m', }, + $9xl: { + height: '$9xl', + width: '$9xl', + borderRadius: '$xl', + }, custom: {}, }, } as const, @@ -61,16 +66,18 @@ export type AvatarProps = ComponentProps & { export const ContactAvatar = React.memo(function ContactAvatComponent({ contactId, + overrideUrl, innerSigilSize, ...props }: { contactId: string; + overrideUrl?: string; innerSigilSize?: number; } & AvatarProps) { const contact = useContact(contactId); return ( void; +}) { + if (blockedContacts.length === 0) { + return ( + + No blocked users + + ); + } + + return ( + + {blockedContacts.map((contact) => ( + onBlockedContactPress(contact)} + showNickname + showEndContent + endContent={} + /> + ))} + + ); +} diff --git a/packages/ui/src/components/ChannelSearch/SearchResults.tsx b/packages/ui/src/components/ChannelSearch/SearchResults.tsx index 995badc081..9c1306c9dd 100644 --- a/packages/ui/src/components/ChannelSearch/SearchResults.tsx +++ b/packages/ui/src/components/ChannelSearch/SearchResults.tsx @@ -53,14 +53,14 @@ export function SearchResults({ Results for " - + {search.query} " Sorted by:{' '} - + most recent diff --git a/packages/ui/src/components/ChannelSearch/SearchStatus.tsx b/packages/ui/src/components/ChannelSearch/SearchStatus.tsx index 7a4ea68b89..5c02bac88c 100644 --- a/packages/ui/src/components/ChannelSearch/SearchStatus.tsx +++ b/packages/ui/src/components/ChannelSearch/SearchStatus.tsx @@ -29,7 +29,7 @@ export function SearchStatus({ )} {numResults > 0 && ( - + {numResults} {` results · `} diff --git a/packages/ui/src/components/ChannelSwitcherSheet.tsx b/packages/ui/src/components/ChannelSwitcherSheet.tsx index 99f37e011a..b3e48ea6a6 100644 --- a/packages/ui/src/components/ChannelSwitcherSheet.tsx +++ b/packages/ui/src/components/ChannelSwitcherSheet.tsx @@ -69,7 +69,7 @@ export function ChannelSwitcherSheet({ diff --git a/packages/ui/src/components/ChatList.tsx b/packages/ui/src/components/ChatList.tsx index 4082d9269c..f51588e2cb 100644 --- a/packages/ui/src/components/ChatList.tsx +++ b/packages/ui/src/components/ChatList.tsx @@ -1,7 +1,9 @@ import * as db from '@tloncorp/shared/dist/db'; import * as logic from '@tloncorp/shared/dist/logic'; import * as store from '@tloncorp/shared/dist/store'; -import { useCallback, useMemo, useRef } from 'react'; +import fuzzy from 'fuzzy'; +import { debounce } from 'lodash'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { NativeScrollEvent, NativeSyntheticEvent, @@ -12,13 +14,31 @@ import { ViewStyle, ViewToken, } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { getTokenValue } from 'tamagui'; -import { useStyle } from '../core'; +import { Text, View, YStack, useStyle } from '../core'; +import { Icon } from './Icon'; +import { Input } from './Input'; import { ChatListItem, SwipableChatListItem } from './ListItem'; +import Pressable from './Pressable'; import { SectionListHeader } from './SectionList'; +import { Tabs } from './Tabs'; + +const DEBOUNCE_DELAY = 200; export type Chat = db.Channel | db.Group; +const AnimatedSectionList = Animated.createAnimatedComponent(SectionList); + export function ChatList({ pinned, unpinned, @@ -26,21 +46,102 @@ export function ChatList({ onLongPressItem, onPressItem, onSectionChange, + activeTab, + setActiveTab, }: store.CurrentChats & { onPressItem?: (chat: Chat) => void; onLongPressItem?: (chat: Chat) => void; onSectionChange?: (title: string) => void; + activeTab: 'all' | 'groups' | 'messages'; + setActiveTab: (tab: 'all' | 'groups' | 'messages') => void; }) { - const data = useMemo(() => { - if (pinned.length === 0) { - return [{ title: 'All', data: [...pendingChats, ...unpinned] }]; + const [searchQuery, setSearchQuery] = useState(''); + const scrollY = useSharedValue(0); + const filterVisible = useSharedValue(false); + const filteredData = useMemo(() => { + const filteredPinned = pinned.filter((c) => { + if (logic.isGroupChannelId(c.id)) { + return activeTab === 'all' || activeTab === 'groups'; + } + return activeTab === 'all' || activeTab === 'messages'; + }); + + const filteredUnpinned = unpinned.filter((c) => { + if (logic.isGroupChannelId(c.id)) { + return activeTab === 'all' || activeTab === 'groups'; + } + return activeTab === 'all' || activeTab === 'messages'; + }); + + return { + filteredPinned, + filteredUnpinned, + }; + }, [activeTab, pinned, unpinned]); + + const sectionedData = useMemo(() => { + const { filteredPinned, filteredUnpinned } = filteredData; + if (filteredPinned.length === 0) { + return [{ title: 'All', data: [...pendingChats, ...filteredUnpinned] }]; } return [ - { title: 'Pinned', data: pinned }, - { title: 'All', data: [...pendingChats, ...unpinned] }, + { title: 'Pinned', data: filteredPinned }, + { title: 'All', data: [...pendingChats, ...filteredUnpinned] }, ]; - }, [pinned, unpinned, pendingChats]); + }, [filteredData, pendingChats]); + + const extractForFuzzy = useCallback((item: db.Channel) => { + if (logic.isGroupChannelId(item.id)) { + return item.group?.title || ''; + } + if (item.type === 'dm' && item.contact) { + return item.contact.nickname + ? `${item.contact.nickname}-${item.id}` + : item.id; + } + if (item.type === 'groupDm') { + return (item.members || []) + .map((m) => + m.contact + ? m.contact.nickname + ? `${m.contact.nickname}-${m.contact.id}` + : m.contact.id + : '' + ) + .join(' '); + } + return item.id; + }, []); + + const debouncedFuzzySearch = useMemo( + () => + debounce((query) => { + const results = fuzzy.filter( + query.trim(), + [...filteredData.filteredPinned, ...filteredData.filteredUnpinned], + { extract: extractForFuzzy } + ); + return results.map((result) => result.original); + }, DEBOUNCE_DELAY), + [filteredData, extractForFuzzy] + ); + + const searchResults = useMemo(() => { + if (searchQuery.trim() === '') { + return sectionedData; + } + const results = debouncedFuzzySearch(searchQuery); + + return results && results.length > 0 + ? [ + { + title: 'Search', + data: results, + }, + ] + : [{ title: 'Search', data: [] }]; + }, [searchQuery, sectionedData, debouncedFuzzySearch]); const contentContainerStyle = useStyle( { @@ -51,16 +152,19 @@ export function ChatList({ ) as StyleProp; const renderItem = useCallback( - ({ item }: SectionListRenderItemInfo) => { + ({ item }: SectionListRenderItemInfo) => { + const itemModel = item as Chat; const baseListItem = ( ); - return logic.isChannel(item) ? ( - {baseListItem} + return logic.isChannel(itemModel) ? ( + + {baseListItem} + ) : ( baseListItem ); @@ -69,10 +173,11 @@ export function ChatList({ ); const renderSectionHeader = useCallback( - ({ section }: { section: SectionListData }) => { + ({ section }: { section: SectionListData }) => { + const sectionItem = section as SectionListData; return ( - {section.title} + {sectionItem.title} ); }, @@ -114,31 +219,156 @@ export function ChatList({ } ).current; - const getChannelKey = useCallback((item: Chat) => { - if (!item || typeof item !== 'object' || !item.id) { + const FILTER_HEIGHT = + getTokenValue('$6xl', 'size') + getTokenValue('$4xl', 'size'); + const HEADER_HEIGHT = getTokenValue('$4xl', 'size'); + const SNAP_THRESHOLD = FILTER_HEIGHT / 2; + + const { top } = useSafeAreaInsets(); + const filterStyle = useAnimatedStyle(() => { + const translateY = interpolate( + scrollY.value, + [-FILTER_HEIGHT, 0], + [0, -FILTER_HEIGHT], + Extrapolation.CLAMP + ); + + return { + height: FILTER_HEIGHT, + transform: [{ translateY: filterVisible.value ? 0 : translateY }], + position: 'absolute', + top: top + HEADER_HEIGHT, + left: 0, + right: 0, + zIndex: 40, + }; + }); + + const listStyle = useAnimatedStyle(() => { + return { + transform: [{ translateY: filterVisible.value ? FILTER_HEIGHT : 0 }], + }; + }); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + if (!filterVisible.value) { + scrollY.value = event.contentOffset.y; + } + }, + onEndDrag: (event) => { + if (event.contentOffset.y < -SNAP_THRESHOLD && !filterVisible.value) { + filterVisible.value = true; + scrollY.value = withSpring(-FILTER_HEIGHT); + } else if ( + event.contentOffset.y > -SNAP_THRESHOLD && + filterVisible.value + ) { + filterVisible.value = false; + scrollY.value = withSpring(0); + } + }, + }); + + const getChannelKey = useCallback((item: unknown) => { + const chatItem = item as Chat; + + if (!chatItem || typeof chatItem !== 'object' || !chatItem.id) { return 'invalid-item'; } - if (logic.isGroup(item)) { - return item.id; + if (logic.isGroup(chatItem)) { + return chatItem.id; } - return `${item.id}-${item.pin?.itemId ?? ''}`; + return `${chatItem.id}-${chatItem.pin?.itemId ?? ''}`; }, []); return ( - + <> + + + + + + + + + + + + setActiveTab('all')} + > + All + + setActiveTab('groups')} + > + Groups + + setActiveTab('messages')} + > + + Messages + + + + + + + {searchQuery !== '' && searchResults.length === 0 ? ( + + No results found. + {activeTab !== 'all' && ( + setActiveTab('all')}> + Try in All? + + )} + setSearchQuery('')}> + Clear search + + + ) : ( + + )} + + ); } diff --git a/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageActions.tsx b/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageActions.tsx index 928629c7ca..677def23c5 100644 --- a/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageActions.tsx +++ b/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageActions.tsx @@ -100,7 +100,7 @@ export function getPostActions({ { id: 'muteThread', label: isMuted ? 'Unmute thread' : 'Mute thread' }, { id: 'copyRef', label: 'Copy link to post' }, { id: 'edit', label: 'Edit message' }, - { id: 'visibility', label: 'Hide' }, + { id: 'visibility', label: post?.hidden ? 'Show post' : 'Hide post' }, { id: 'delete', label: 'Delete message', actionType: 'destructive' }, ]; case 'notebook': @@ -110,7 +110,7 @@ export function getPostActions({ { id: 'pin', label: 'Pin post' }, { id: 'copyRef', label: 'Copy link to post' }, { id: 'edit', label: 'Edit message' }, - { id: 'visibility', label: 'Hide' }, + { id: 'visibility', label: post?.hidden ? 'Show post' : 'Hide post' }, { id: 'delete', label: 'Delete message', actionType: 'destructive' }, ]; case 'dm': @@ -120,7 +120,7 @@ export function getPostActions({ { id: 'startThread', label: 'Start thread' }, { id: 'muteThread', label: isMuted ? 'Unmute thread' : 'Mute thread' }, { id: 'copyText', label: 'Copy message text' }, - { id: 'visibility', label: 'Hide' }, + { id: 'visibility', label: post?.hidden ? 'Show post' : 'Hide post' }, { id: 'delete', label: 'Delete message', actionType: 'destructive' }, ]; case 'chat': diff --git a/packages/ui/src/components/ChatMessage/ChatMessageReplySummary.tsx b/packages/ui/src/components/ChatMessage/ChatMessageReplySummary.tsx index 56ee7cb377..64788709d4 100644 --- a/packages/ui/src/components/ChatMessage/ChatMessageReplySummary.tsx +++ b/packages/ui/src/components/ChatMessage/ChatMessageReplySummary.tsx @@ -10,9 +10,13 @@ export const ChatMessageReplySummary = React.memo( function ChatMessageReplySummary({ post, onPress, + paddingLeft = true, }: { post: db.Post; onPress?: () => void; + // Since this component is used in places other than a chat log, we need to + // be able to toggle the Chat message padding on and off + paddingLeft?: boolean; }) { const { replyCount, replyTime, replyContactIds, threadUnread } = post; @@ -21,7 +25,7 @@ export const ChatMessageReplySummary = React.memo( }, [replyTime]); return replyCount && replyContactIds && replyTime ? ( - + {replyContactIds?.map((c, i) => ( { - return {children}; +const ContactListFrameComponent = ({ + children, + ...rest +}: PropsWithChildren>) => { + return {children}; }; export const ContactList = withStaticProperties(ContactListFrameComponent, { diff --git a/packages/ui/src/components/ContentReference/ChannelReference.tsx b/packages/ui/src/components/ContentReference/ChannelReference.tsx index d30d9c8ca1..ed5db00fdc 100644 --- a/packages/ui/src/components/ContentReference/ChannelReference.tsx +++ b/packages/ui/src/components/ContentReference/ChannelReference.tsx @@ -45,7 +45,6 @@ export default function ChannelReference({ } if (channelType === 'gallery') { - // TODO: Implement gallery reference return ( diff --git a/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx b/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx index d0d0f73d86..03a51f3c9f 100644 --- a/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx +++ b/packages/ui/src/components/ContentReference/ChatReferenceWrapper.tsx @@ -35,16 +35,18 @@ export default function ChatReferenceWrapper({ ); } if (!post) { if (isLoading) { - return ; + return ; } return ( diff --git a/packages/ui/src/components/ContentReference/GroupReference.tsx b/packages/ui/src/components/ContentReference/GroupReference.tsx index 29df5642c0..7423f96e63 100644 --- a/packages/ui/src/components/ContentReference/GroupReference.tsx +++ b/packages/ui/src/components/ContentReference/GroupReference.tsx @@ -6,11 +6,18 @@ import { useRequests } from '../../contexts/requests'; import { getGroupHost } from '../../utils'; import { ContactAvatar } from '../Avatar'; import ContactName from '../ContactName'; +import { PostViewMode } from '../ContentRenderer'; import { ListItem } from '../ListItem'; import { LoadingSpinner } from '../LoadingSpinner'; -import { Reference } from './Reference'; +import { REF_AUTHOR_WIDTH, Reference } from './Reference'; -export function GroupReference({ groupId }: { groupId: string }) { +export function GroupReference({ + groupId, + viewMode = 'chat', +}: { + groupId: string; + viewMode?: PostViewMode; +}) { const { useGroup } = useRequests(); const { onPressGroupRef } = useNavigation(); const { data: group, isLoading, isError } = useGroup(groupId); @@ -24,7 +31,7 @@ export function GroupReference({ groupId }: { groupId: string }) { }, [group, onPressGroupRef]); return ( - + @@ -32,6 +39,7 @@ export function GroupReference({ groupId }: { groupId: string }) { color="$tertiaryText" size="$s" userId={host} + maxWidth={REF_AUTHOR_WIDTH} showNickname /> diff --git a/packages/ui/src/components/ContentReference/Reference.tsx b/packages/ui/src/components/ContentReference/Reference.tsx index cf3f5d9b68..8396a8fb3c 100644 --- a/packages/ui/src/components/ContentReference/Reference.tsx +++ b/packages/ui/src/components/ContentReference/Reference.tsx @@ -7,6 +7,8 @@ import { PostViewMode } from '../ContentRenderer'; import { Icon } from '../Icon'; import Pressable from '../Pressable'; +export const REF_AUTHOR_WIDTH = 230; + export type ReferenceProps = { onPress: () => void; }; @@ -40,6 +42,9 @@ const ReferenceFrame = styled(YStack, { }, block: { backgroundColor: '$secondaryBackground', + borderWidth: 0, + borderRadius: 0, + marginBottom: 0, }, note: { marginLeft: 0, diff --git a/packages/ui/src/components/ContentReference/ReferenceSkeleton.tsx b/packages/ui/src/components/ContentReference/ReferenceSkeleton.tsx index da01747f3c..294f6aaa11 100644 --- a/packages/ui/src/components/ContentReference/ReferenceSkeleton.tsx +++ b/packages/ui/src/components/ContentReference/ReferenceSkeleton.tsx @@ -1,23 +1,26 @@ import { ComponentProps } from 'react'; import { Text, XStack, YStack } from '../../core'; +import { PostViewMode } from '../ContentRenderer'; import { Icon } from '../Icon'; import { LoadingSpinner } from '../LoadingSpinner'; export default function ReferenceSkeleton({ message = 'Loading', messageType = 'loading', + viewMode = 'chat', ...props }: { message?: string; messageType?: 'loading' | 'error' | 'not-found'; + viewMode?: PostViewMode; } & ComponentProps) { return ( diff --git a/packages/ui/src/components/ContentReference/index.tsx b/packages/ui/src/components/ContentReference/index.tsx index bba0bbdb5b..13e3c315fd 100644 --- a/packages/ui/src/components/ContentReference/index.tsx +++ b/packages/ui/src/components/ContentReference/index.tsx @@ -27,12 +27,13 @@ export default function ContentReference({ } if (reference.referenceType === 'group') { - return ; + return ; } if (reference.referenceType === 'app') { return ( diff --git a/packages/ui/src/components/ContentRenderer.tsx b/packages/ui/src/components/ContentRenderer.tsx index a52c4d5909..1de7023486 100644 --- a/packages/ui/src/components/ContentRenderer.tsx +++ b/packages/ui/src/components/ContentRenderer.tsx @@ -705,14 +705,12 @@ const LineRenderer = memo( ) : ( // not clear if this is necessary )} @@ -742,7 +740,6 @@ const LineRenderer = memo( color={color} onPressImage={onPressImage} onLongPress={onLongPress} - serif={serif} /> ); } @@ -774,7 +771,6 @@ const LineRenderer = memo( } key={`line-${index}`} flexWrap="wrap" - fontFamily={serif ? '$serif' : '$body'} > {line} @@ -792,6 +788,7 @@ export type PostViewMode = 'chat' | 'block' | 'note' | 'activity'; export default function ContentRenderer({ post, shortened = false, + shortenedTextOnly = false, isNotice = false, deliveryStatus, onPressImage, @@ -801,6 +798,7 @@ export default function ContentRenderer({ }: { post: Post | { type: 'chat' | 'diary' | 'gallery'; id: string; content: any }; shortened?: boolean; + shortenedTextOnly?: boolean; isNotice?: boolean; deliveryStatus?: PostDeliveryStatus | null; onPressImage?: (src: string) => void; @@ -861,7 +859,6 @@ export default function ContentRenderer({ ) : null} {post.type === 'note' && post.title ? ( + + ); + } + + if (shortenedTextOnly) { + return ( + + ); @@ -886,13 +896,7 @@ export default function ContentRenderer({ {story.map((s, k) => { if ('block' in s) { - return ( - - ); + return ; } if ('type' in s && s.type === 'reference') { @@ -908,7 +912,6 @@ export default function ContentRenderer({ onPressImage={onPressImage} onLongPress={onLongPress} viewMode={viewMode} - serif={post.type === 'note'} /> ); } diff --git a/packages/ui/src/components/DetailView/DetailView.tsx b/packages/ui/src/components/DetailView/DetailView.tsx index 3618642be0..4977f021cd 100644 --- a/packages/ui/src/components/DetailView/DetailView.tsx +++ b/packages/ui/src/components/DetailView/DetailView.tsx @@ -46,8 +46,11 @@ const DetailViewMetaDataComponent = ({ return makePrettyShortDate(date); }, [post.sentAt]); + const hasReplies = post.replyCount! > 0; + return ( - + + {dateDisplay} - - {dateDisplay} - - {showReplyCount && ( + {showReplyCount && hasReplies && ( - {post.replyCount} replies + {post.replyCount} {post.replyCount === 1 ? 'reply' : 'replies'} )} @@ -83,16 +83,6 @@ const DetailViewHeaderComponentFrame = ({ > {children} - - - {replyCount} replies - - ); }; @@ -172,7 +162,7 @@ const DetailViewFrameComponent = ({ getDraft={getDraft} backgroundColor="$background" showAttachmentButton={false} - placeholder="Reply to post" + placeholder="Reply" setHeight={setMessageInputHeight} // TODO: add back in when we switch to bottom nav // goBack={goBack} diff --git a/packages/ui/src/components/DetailView/GalleryDetailView.tsx b/packages/ui/src/components/DetailView/GalleryDetailView.tsx index 52567dbc7f..c4b7b86e33 100644 --- a/packages/ui/src/components/DetailView/GalleryDetailView.tsx +++ b/packages/ui/src/components/DetailView/GalleryDetailView.tsx @@ -4,6 +4,7 @@ import { Dimensions } from 'react-native'; import { Image, Text, View, YStack } from '../../core'; import ContentReference from '../ContentReference'; import ContentRenderer from '../ContentRenderer'; +import { Icon } from '../Icon'; import { DetailView, DetailViewProps } from './DetailView'; export default function GalleryDetailView({ @@ -32,6 +33,7 @@ export default function GalleryDetailView({ references, isText, isImage, + isLink, isReference, isLinkedImage, isRefInText, @@ -92,44 +94,45 @@ export default function GalleryDetailView({ )} {isText && !isLinkedImage && !isRefInText && ( + {isLink && } )} {(isReference || isRefInText) && ( - - + + )} - + + + ); diff --git a/packages/ui/src/components/DetailView/NotebookDetailView.tsx b/packages/ui/src/components/DetailView/NotebookDetailView.tsx index ed39b37011..b7d6d6a545 100644 --- a/packages/ui/src/components/DetailView/NotebookDetailView.tsx +++ b/packages/ui/src/components/DetailView/NotebookDetailView.tsx @@ -66,20 +66,12 @@ export default function NotebookDetailView({ )} {post.title && ( - + {post.title} )} - - - - + + ); diff --git a/packages/ui/src/components/EditProfileScreenView.tsx b/packages/ui/src/components/EditProfileScreenView.tsx new file mode 100644 index 0000000000..26d36d62c7 --- /dev/null +++ b/packages/ui/src/components/EditProfileScreenView.tsx @@ -0,0 +1,110 @@ +import * as api from '@tloncorp/shared/dist/api'; +import * as db from '@tloncorp/shared/dist/db'; +import { useForm } from 'react-hook-form'; +import { Keyboard } from 'react-native'; + +import { useContact } from '../contexts'; +import { ScrollView, View, YStack } from '../core'; +import { EditablePofileImages } from './EditableProfileImages'; +import { FormTextInput } from './FormInput'; +import { GenericHeader } from './GenericHeader'; +import { SaveButton } from './GroupMetaScreenView'; +import KeyboardAvoidingView from './KeyboardAvoidingView'; + +interface Props { + currentUserId: string; + uploadInfo: api.UploadInfo; + onGoBack: () => void; + onSaveProfile: (update: api.ProfileUpdate) => void; +} + +export function EditProfileScreenView(props: Props) { + const userContact = useContact(props.currentUserId); + const { + control, + handleSubmit, + formState: { errors }, + setValue, + } = useForm({ + defaultValues: { + nickname: userContact?.nickname ?? '', + bio: userContact?.bio ?? '', + avatarImage: userContact?.avatarImage ?? '', + coverImage: userContact?.coverImage ?? '', + }, + }); + + return ( + + + } + /> + + + + setValue('coverImage', url)} + onSetIconUrl={(url) => setValue('avatarImage', url)} + /> + + + Nickname + + + + + Bio + + + + + + + ); +} diff --git a/packages/ui/src/components/EditableProfileImages.tsx b/packages/ui/src/components/EditableProfileImages.tsx new file mode 100644 index 0000000000..d2a7063d8b --- /dev/null +++ b/packages/ui/src/components/EditableProfileImages.tsx @@ -0,0 +1,188 @@ +import * as api from '@tloncorp/shared/dist/api'; +import * as db from '@tloncorp/shared/dist/db'; +import { ImageBackground } from 'expo-image'; +import { + ComponentProps, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { TouchableOpacity } from 'react-native'; +import { Circle, Stack, ZStack, useTheme } from 'tamagui'; + +import { View } from '../core'; +import AttachmentSheet from './AttachmentSheet'; +import { AvatarProps, ContactAvatar, GroupAvatar } from './Avatar'; +import { Icon } from './Icon'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface Props { + contact?: db.Contact; + group?: db.Group; + iconProps?: AvatarProps; + uploadInfo: api.UploadInfo; + onSetCoverUrl: (url: string) => void; + onSetIconUrl: (url: string) => void; +} + +export function EditablePofileImages(props: Props) { + const theme = useTheme(); + const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); + const [attachingTo, setAttachingTo] = useState(null); + const [iconUrl, setIconUrl] = useState( + props.contact?.avatarImage ?? props.group?.iconImage ?? '' + ); + const [coverUrl, setCoverUrl] = useState( + props.contact?.coverImage ?? props.group?.coverImage ?? '' + ); + + useEffect(() => { + if ( + props.uploadInfo.imageAttachment && + props.uploadInfo.uploadedImage && + !props.uploadInfo.uploading && + props.uploadInfo.uploadedImage?.url !== '' && + props.uploadInfo !== null + ) { + const uploadedFile = props.uploadInfo.uploadedImage as api.UploadedFile; + if (attachingTo === 'cover') { + setCoverUrl(uploadedFile.url); + props.onSetCoverUrl(uploadedFile.url); + } else if (attachingTo === 'icon') { + setIconUrl(uploadedFile.url); + props.onSetIconUrl(uploadedFile.url); + } + + setAttachingTo(null); + props.uploadInfo.resetImageAttachment(); + } + }, [props.uploadInfo, attachingTo, props]); + + const coverIsUploading = useMemo(() => { + return props.uploadInfo.uploading && attachingTo === 'cover'; + }, [attachingTo, props.uploadInfo.uploading]); + + const iconIsUploading = useMemo(() => { + return props.uploadInfo.uploading && attachingTo === 'icon'; + }, [attachingTo, props.uploadInfo.uploading]); + + const handleCoverPress = useCallback(() => { + if (props.uploadInfo.canUpload) { + setShowAttachmentSheet(true); + setAttachingTo('cover'); + } + }, [props.uploadInfo]); + + const handleIconPress = useCallback(() => { + if (props.uploadInfo.canUpload) { + setShowAttachmentSheet(true); + setAttachingTo('icon'); + } + }, [props.uploadInfo]); + + return ( + + + {/* Cover Image */} + + + {coverIsUploading && ( + + + + )} + + + + + {/* Profile Icon */} + + + {props.contact && ( + + )} + {props.group && ( + + )} + + + + + + { + props.uploadInfo.setAttachments(attachments); + }} + /> + + ); +} + +export function EditableImageIndicator( + props: ComponentProps & { loading?: boolean } +) { + return ( + + + + ); +} diff --git a/packages/ui/src/components/FeatureFlagScreenView.tsx b/packages/ui/src/components/FeatureFlagScreenView.tsx index be06830d07..c6e88bf6ac 100644 --- a/packages/ui/src/components/FeatureFlagScreenView.tsx +++ b/packages/ui/src/components/FeatureFlagScreenView.tsx @@ -18,7 +18,7 @@ export function FeatureFlagScreenView({ return ( - + , 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled' >; + frameProps?: ComponentProps; + areaProps?: ComponentProps; placeholder?: string; }) { return ( @@ -35,13 +41,14 @@ export function FormInput({ rules={rules} render={({ field: { onChange, onBlur, value } }) => ( - + @@ -51,3 +58,40 @@ export function FormInput({ ); } + +export const FormInputContext = createStyledContext<{ + name: string; + control: Control; +}>(); + +const FormInputFrame = styled(YStack, { + context: FormInputContext, + padding: '$m', +}); + +const FormLabel = styled(SizableText, { + context: FormInputContext, + color: '$secondaryText', + fontSize: '$s', +}); + +const FormErrors = (props: { errors: DeepMap }) => { + const context = FormInputContext.useStyledContext(); + if (!props.errors[context.name]) { + return null; + } + + return ( + + + {props.errors[context.name].message} + + + ); +}; + +export const FormTextInput = withStaticProperties(FormInputFrame, { + Input: FormInput, + Label: FormLabel, + Error: FormErrors, +}); diff --git a/packages/ui/src/components/GalleryPost/GalleryPost.tsx b/packages/ui/src/components/GalleryPost/GalleryPost.tsx index 0563df3dd7..b391bdf66f 100644 --- a/packages/ui/src/components/GalleryPost/GalleryPost.tsx +++ b/packages/ui/src/components/GalleryPost/GalleryPost.tsx @@ -7,6 +7,7 @@ import { ImageWithFallback, View } from '../../core'; import AuthorRow from '../AuthorRow'; import ContentReference from '../ContentReference'; import ContentRenderer from '../ContentRenderer'; +import { Icon } from '../Icon'; import { useBoundHandler } from '../ListItem/listItemUtils'; const GalleryPostFrame = styled(View, { @@ -24,6 +25,10 @@ const GalleryPostFrame = styled(View, { borderWidth: 0, }, text: {}, + link: { + borderWidth: 0, + backgroundColor: '$secondaryBackground', + }, reference: { borderWidth: 0, backgroundColor: '$secondaryBackground', @@ -51,6 +56,7 @@ export default function GalleryPost({ references, isText, isImage, + isLink, isReference, isLinkedImage, isRefInText, @@ -61,14 +67,21 @@ export default function GalleryPost({ const handlePress = useBoundHandler(post, onPress); const handleLongPress = useBoundHandler(post, onLongPress); - const previewType = - isImage || isLinkedImage - ? 'image' - : isText && !isLinkedImage && !isRefInText - ? 'text' - : isReference || isRefInText - ? 'reference' - : 'unsupported'; + const previewType = (() => { + if (isImage || isLinkedImage) { + return 'image'; + } + if (isText && !isLinkedImage && !isRefInText && !isLink) { + return 'text'; + } + if (isReference || isRefInText) { + return 'reference'; + } + if (isLink) { + return 'link'; + } + return 'unsupported'; + })(); return ( - - {/** Image post */} - {(isImage || isLinkedImage) && ( - - Failed to load {isImage ? image!.src : linkedImage} - - } - source={{ uri: isImage ? image!.src : linkedImage }} - /> - )} + {post.hidden ? ( + + + You have hidden or flagged this post. + + + ) : ( + + {/** Image post */} + {(isImage || isLinkedImage) && ( + + Failed to load {isImage ? image!.src : linkedImage} + + } + source={{ uri: isImage ? image!.src : linkedImage }} + /> + )} - {/** Text post */} - {isText && !isLinkedImage && !isRefInText && ( - - - - )} + {/** Text post */} + {isText && !isLinkedImage && !isRefInText && ( + + {isLink && } + + + )} - {/** Reference post */} - {(isReference || isRefInText) && ( - - - - )} + {/** Reference post */} + {(isReference || isRefInText) && ( + + + + )} - {/** Unsupported post */} - {!isImage && !isText && !isReference && !isRefInText ? ( - Unable to parse content - ) : null} + {/** Unsupported post */} + {!isImage && !isText && !isReference && !isRefInText ? ( + Unable to parse content + ) : null} - {viewMode !== 'activity' && ( - - - - )} - + {viewMode !== 'activity' && ( + + + + {/** Text post */} + {isText && !isLinkedImage && !isRefInText && ( + + + + )} + + {/** Reference post */} + {(isReference || isRefInText) && ( + + + + )} + + {/** Unsupported post */} + {!isImage && !isText && !isReference && !isRefInText ? ( + Unable to parse content + ) : null} + + {viewMode !== 'activity' && ( + + + + )} + + )} + + )} ); } diff --git a/packages/ui/src/components/GenericHeader.tsx b/packages/ui/src/components/GenericHeader.tsx index f2dfde1685..59dd2951cd 100644 --- a/packages/ui/src/components/GenericHeader.tsx +++ b/packages/ui/src/components/GenericHeader.tsx @@ -49,7 +49,7 @@ export function GenericHeader({ numberOfLines={1} color="$primaryText" size="$m" - fontWeight="500" + fontWeight="$xl" > {showSpinner ? 'Loading…' : title} diff --git a/packages/ui/src/components/GroupMetaScreenView.tsx b/packages/ui/src/components/GroupMetaScreenView.tsx index 98ed2d34cc..c49b36bf9f 100644 --- a/packages/ui/src/components/GroupMetaScreenView.tsx +++ b/packages/ui/src/components/GroupMetaScreenView.tsx @@ -1,24 +1,17 @@ -import { - MessageAttachments, - UploadInfo, - UploadedFile, -} from '@tloncorp/shared/dist/api'; +import { MessageAttachments, UploadInfo } from '@tloncorp/shared/dist/api'; import * as db from '@tloncorp/shared/dist/db'; -import { ImageBackground } from 'expo-image'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { getTokenValue } from 'tamagui'; -import { Text, View, YStack } from '../core'; +import { View, YStack } from '../core'; import AttachmentSheet from './AttachmentSheet'; -import { GroupAvatar } from './Avatar'; import { Button } from './Button'; import { DeleteSheet } from './DeleteSheet'; +import { EditablePofileImages } from './EditableProfileImages'; import { FormInput } from './FormInput'; import { GenericHeader } from './GenericHeader'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import { LoadingSpinner } from './LoadingSpinner'; -import Pressable from './Pressable'; interface GroupMetaScreenViewProps { group: db.Group | null; @@ -33,81 +26,12 @@ interface GroupMetaScreenViewProps { export function SaveButton({ onPress }: { onPress: () => void }) { return ( - ); } -function ExplanationPressable({ - onPress, - canUpload, -}: { - onPress: () => void; - canUpload: boolean; -}) { - return ( - - - - {canUpload - ? 'Tap here to change the cover image. Tap the icon to change the icon.' - : 'You need to set up image hosting before you can upload'} - - - - ); -} - -function GroupIconPressable({ - group, - onPress, - iconImage, - uploading, -}: { - uploading: boolean; - group: db.Group; - iconImage: string; - onPress: () => void; -}) { - if (uploading) { - return ( - - - - ); - } - - return ( - - - - ); -} - export function GroupMetaScreenView({ group, setGroupMetadata, @@ -117,12 +41,10 @@ export function GroupMetaScreenView({ }: GroupMetaScreenViewProps) { const [showDeleteSheet, setShowDeleteSheet] = useState(false); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); - const [attachingTo, setAttachingTo] = useState(null); const { control, handleSubmit, formState: { errors }, - getValues, setValue, } = useForm({ defaultValues: { @@ -133,28 +55,6 @@ export function GroupMetaScreenView({ }, }); - const { coverImage, iconImage } = getValues(); - - useEffect(() => { - if ( - uploadInfo.imageAttachment && - uploadInfo.uploadedImage && - !uploadInfo.uploading && - uploadInfo.uploadedImage?.url !== '' && - attachingTo !== null - ) { - const uploadedFile = uploadInfo.uploadedImage as UploadedFile; - - setValue( - attachingTo === 'cover' ? 'coverImage' : 'iconImage', - uploadedFile.url - ); - - setAttachingTo(null); - uploadInfo.resetImageAttachment(); - } - }, [uploadInfo, attachingTo, setValue]); - const onSubmit = useCallback( (data: { title: string; @@ -188,84 +88,12 @@ export function GroupMetaScreenView({ /> - {uploadInfo.uploading && attachingTo === 'cover' ? ( - - - - ) : coverImage ? ( - - - { - if (uploadInfo.canUpload) { - setShowAttachmentSheet(true); - setAttachingTo('cover'); - } - }} - canUpload={uploadInfo.canUpload} - /> - { - if (uploadInfo.canUpload) { - setShowAttachmentSheet(true); - setAttachingTo('icon'); - } - }} - uploading={uploadInfo.uploading && attachingTo === 'icon'} - /> - - - ) : ( - - { - if (uploadInfo.canUpload) { - setShowAttachmentSheet(true); - setAttachingTo('cover'); - } - }} - canUpload={uploadInfo.canUpload} - /> - { - if (uploadInfo.canUpload) { - setShowAttachmentSheet(true); - setAttachingTo('icon'); - } - }} - uploading={uploadInfo.uploading && attachingTo === 'icon'} - /> - - )} + setValue('coverImage', url)} + onSetIconUrl={(url) => setValue('iconImage', url)} + /> ) => ( + {showEndContent && ( + + {endContent} + + )} ); diff --git a/packages/ui/src/components/NotebookPost/NotebookPost.tsx b/packages/ui/src/components/NotebookPost/NotebookPost.tsx index e58b72bcc3..4ac70fd3bd 100644 --- a/packages/ui/src/components/NotebookPost/NotebookPost.tsx +++ b/packages/ui/src/components/NotebookPost/NotebookPost.tsx @@ -4,6 +4,8 @@ import { useCallback, useMemo } from 'react'; import { Image, Text, YStack } from '../../core'; import AuthorRow from '../AuthorRow'; +import { ChatMessageReplySummary } from '../ChatMessage/ChatMessageReplySummary'; +import ContentRenderer from '../ContentRenderer'; import Pressable from '../Pressable'; const IMAGE_HEIGHT = 268; @@ -29,12 +31,6 @@ export default function NotebookPost({ smallTitle?: boolean; viewMode?: 'activity'; }) { - const dateDisplay = useMemo(() => { - const date = new Date(post.sentAt); - - return makePrettyShortDate(date); - }, [post.sentAt]); - const handleLongPress = useCallback(() => { onLongPress?.(post); }, [post, onLongPress]); @@ -43,9 +39,11 @@ export default function NotebookPost({ return null; } + const hasReplies = post.replyCount! > 0; + return ( onPress?.(post)} + onPress={() => (post.hidden ? () => {} : onPress?.(post))} onLongPress={handleLongPress} delayLongPress={250} disabled={viewMode === 'activity'} @@ -53,56 +51,62 @@ export default function NotebookPost({ - {post.image && ( - - )} - {post.title && ( - - {post.title} - - )} - {showAuthor && viewMode !== 'activity' && ( - - )} - - {dateDisplay} - - {showReplies && ( - - {post.replyCount} replies + {post.hidden ? ( + + You have hidden or flagged this post. + ) : ( + <> + {post.image && ( + + )} + {post.title && ( + + {post.title} + + )} + {showAuthor && viewMode !== 'activity' && ( + + )} + {viewMode !== 'activity' && ( + + )} + + {/* TODO: reuse reply stack from Chat messages */} + {showReplies && + hasReplies && + post.replyCount && + post.replyTime && + post.replyContactIds ? ( + + ) : null} + )} diff --git a/packages/ui/src/components/ProfileRow.tsx b/packages/ui/src/components/ProfileRow.tsx index 02ccd725b1..f127126eb4 100644 --- a/packages/ui/src/components/ProfileRow.tsx +++ b/packages/ui/src/components/ProfileRow.tsx @@ -36,7 +36,7 @@ export default function ProfileRow({ @@ -48,7 +48,7 @@ export default function ProfileRow({ /> ) : ( - + )} diff --git a/packages/ui/src/components/ProfileScreenView.tsx b/packages/ui/src/components/ProfileScreenView.tsx index f84f1603f2..e28643f82f 100644 --- a/packages/ui/src/components/ProfileScreenView.tsx +++ b/packages/ui/src/components/ProfileScreenView.tsx @@ -1,24 +1,20 @@ import * as db from '@tloncorp/shared/dist/db'; -import * as store from '@tloncorp/shared/dist/store'; -import * as ub from '@tloncorp/shared/dist/urbit'; -import { useCallback, useState } from 'react'; -import { Dimensions } from 'react-native'; +import { Alert, Dimensions, TouchableOpacity } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ScrollView, SizableText, XStack, getTokens } from 'tamagui'; +import { ScrollView, SizableText, getTokens } from 'tamagui'; import { ContactsProvider, useContact } from '../contexts'; -import { View, YStack } from '../core'; -import { Icon, IconType } from './Icon'; +import { Stack, View, YStack } from '../core'; +import { IconType } from './Icon'; import { ListItem } from './ListItem'; -import { LoadingSpinner } from './LoadingSpinner'; import ProfileCover from './ProfileCover'; import ProfileRow from './ProfileRow'; interface Props { currentUserId: string; - debugMessage: string; onAppSettingsPressed?: () => void; - handleLogout: () => void; + onEditProfilePressed?: () => void; + onLogoutPressed: () => void; } export function ProfileScreenView({ @@ -32,163 +28,70 @@ export function ProfileScreenView({ ); } -type NotificationState = { open: boolean; setting: 1 | 2 | 3 }; - export function Wrapped(props: Props) { - const [loading, setLoading] = useState(null); - const { data: pushNotificationsSetting } = - store.usePushNotificationsSetting(); const { top } = useSafeAreaInsets(); const contact = useContact(props.currentUserId); - const [notifState, setNotifState] = useState({ - open: false, - setting: 1, - }); - - const setLevel = useCallback( - async (level: ub.PushNotificationsSetting) => { - if (level === pushNotificationsSetting) return; - setLoading(level); - await store.setDefaultNotificationLevel(level); - setLoading(null); - }, - [pushNotificationsSetting] - ); - - const LevelIndicator = useCallback( - (props: { level: ub.PushNotificationsSetting }) => { - if (loading === props.level) { - return ; - } - - if (pushNotificationsSetting === props.level) { - return ( - - - - ); - } - - return ( - - ); - }, - [pushNotificationsSetting, loading] - ); // TODO: Add logout back in when we figure out TLON-2098. - // const onLogoutPress = () => { - // Alert.alert('Log out', 'Are you sure you want to log out?', [ - // { - // text: 'Cancel', - // style: 'cancel', - // }, - // { - // text: 'Log out', - // onPress: props.handleLogout, - // }, - // ]); - // }; + const onLogoutPress = () => { + Alert.alert('Log out', 'Are you sure you want to log out?', [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Log out', + onPress: props.onLogoutPressed, + }, + ]); + }; return ( - {!notifState.open && ( - <> - - {contact ? ( - - ) : ( - - - - )} - - - - - setNotifState((prev) => ({ ...prev, open: true })) - } - /> - {/* */} + + {contact ? ( + + ) : ( + + - - )} - {notifState.open && ( - - - setNotifState({ open: false, setting: 1 })} - /> - - Push Notification Settings - - - - - Configure what kinds of messages will send you notifications. - - - - setLevel('all')}> - - All group activity - - - setLevel('some')}> - - - Mentions and replies only - - Direct messages will still notify unless you mute them. - - - - - setLevel('none')}> - - Nothing - - + )} + + + + + Edit + + + - )} + + + + + ); @@ -197,11 +100,9 @@ export function Wrapped(props: Props) { export function ProfileDisplayWidget({ contact, contactId, - debugMessage, }: { contact: db.Contact; contactId: string; - debugMessage: string; }) { const coverSize = Dimensions.get('window').width - getTokens().space.$xl.val * 2; @@ -209,24 +110,13 @@ export function ProfileDisplayWidget({ return ( - + ); } - return ( - - ); + return ; } function ProfileAction({ diff --git a/packages/ui/src/components/ProfileSheet.tsx b/packages/ui/src/components/ProfileSheet.tsx index 0feaec7fa1..33b1a114ae 100644 --- a/packages/ui/src/components/ProfileSheet.tsx +++ b/packages/ui/src/components/ProfileSheet.tsx @@ -1,5 +1,6 @@ import Clipboard from '@react-native-clipboard/clipboard'; import * as db from '@tloncorp/shared/dist/db'; +import * as store from '@tloncorp/shared/dist/store'; import { useCallback } from 'react'; import { Dimensions } from 'react-native'; import { getTokens } from 'tamagui'; @@ -51,9 +52,13 @@ export function ProfileSheet({ const { onPressGoToDm } = useNavigation(); const handleBlock = useCallback(() => { - console.log('block not yet implemented', contactId); + if (contact && contact.isBlocked) { + store.unblockUser(contactId); + } else { + store.blockUser(contactId); + } onOpenChange(false); - }, [contactId, onOpenChange]); + }, [contact, contactId, onOpenChange]); const handleGoToDm = useCallback(async () => { onPressGoToDm([contactId]); @@ -89,8 +94,7 @@ export function ProfileSheet({ diff --git a/packages/ui/src/components/Tabs.tsx b/packages/ui/src/components/Tabs.tsx new file mode 100644 index 0000000000..89abca8e12 --- /dev/null +++ b/packages/ui/src/components/Tabs.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; +import { styled, withStaticProperties } from 'tamagui'; + +import { SizableText, XStack } from '../core'; +import Pressable from './Pressable'; + +const TabsWrapper = styled(XStack, { + width: '100%', +}); + +const TabTitleComponent = styled(SizableText, { + width: 100, + textAlign: 'center', + paddingVertical: '$m', + variants: { + active: { + true: { + color: '$primaryText', + }, + false: { + color: '$secondaryText', + }, + }, + } as const, +}); + +const TabComponent = ({ + onTabPress, + children, + name, + activeTab, +}: { + onTabPress: () => void; + children: ReactNode; + name: string; + activeTab: string; +}) => { + return ( + + onTabPress()}>{children} + + ); +}; + +export const Tabs = withStaticProperties(TabsWrapper, { + Tab: TabComponent, + Title: TabTitleComponent, +}); diff --git a/packages/ui/src/components/WelcomeSheet.tsx b/packages/ui/src/components/WelcomeSheet.tsx index debc1eb807..d2ff13a320 100644 --- a/packages/ui/src/components/WelcomeSheet.tsx +++ b/packages/ui/src/components/WelcomeSheet.tsx @@ -35,7 +35,7 @@ export function WelcomeSheet({ paddingHorizontal="$2xl" > - + Welcome to Tlon - A messenger you can actually trust. + A messenger you can finally trust. @@ -61,10 +61,9 @@ export function WelcomeSheet({ - Message privately + Control every bit - When you chat, it’s just your computer talking to my - computer. No middlemen in between. + Whatever you do, say, and make on Tlon is yours to keep @@ -75,14 +74,14 @@ export function WelcomeSheet({ borderRadius={'$3xl'} padding="$m" > - + - Start a community + From now until forever - Get a group together for a shared purpose; stay because - it’s free from spying. + With Tlon you can always take your data with you and continue + using it elsewhere @@ -93,14 +92,14 @@ export function WelcomeSheet({ borderRadius={'$3xl'} padding="$m" > - + - Grow with confidence + Connect with calm - Tlon is built for longevity: your community won’t - disappear and can’t be disappeared. + Tlon is designed to maximize genuine connection, not addictive + engagement diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3321dec31d..3f4c281666 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -36,18 +36,22 @@ export * from './components/SectionList'; export * from './components/ContactRow'; export * from './components/Button'; export * from './components/ScreenHeader'; +export * from './components/GenericHeader'; export * from './components/IconButton'; export * from './components/Channel/ChannelHeader'; export * from './components/Channel/BaubleHeader'; export * from './components/ChatMessage'; export * from './components/GalleryPost'; export * from './components/ContactList'; +export * from './components/BlockedContactsWidget'; export * from './components/Channel/ChannelDivider'; export * from './components/Embed'; export * from './components/AddChats'; export * from './components/ContactBook'; export * from './components/Buttons'; export * from './components/Avatar'; +export * from './components/EditProfileScreenView'; +export * from './components/AppSetting'; export * from './components/GroupChannelsScreenView'; export * from './components/FeatureFlagScreenView'; export * from './tamagui.config'; diff --git a/packages/ui/src/tamagui.config.ts b/packages/ui/src/tamagui.config.ts index 1f6e0248fc..1ff064f155 100644 --- a/packages/ui/src/tamagui.config.ts +++ b/packages/ui/src/tamagui.config.ts @@ -68,6 +68,7 @@ export const tokens = createTokens({ '4xl': 48, '5xl': 64, '6xl': 72, + '9xl': 96, // TODO: worth leaving room between? }, radius: { '2xs': 2, @@ -258,6 +259,7 @@ export const systemFont = createFont({ m: 'regular', true: 'regular', l: 'medium', + xl: '500', }, letterSpacing: { s: 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3b662ecdc..6aafed48d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,10 +73,10 @@ importers: version: 15.2.0 tsup: specifier: ^8.0.1 - version: 8.0.1(@swc/core@1.4.16(@swc/helpers@0.5.10))(postcss@8.4.35)(typescript@5.4.5) + version: 8.0.1(@swc/core@1.4.16)(postcss@8.4.35)(typescript@5.4.5) vitest: specifier: ^1.2.2 - version: 1.2.2(@types/node@20.10.8)(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1) + version: 1.2.2(@types/node@20.14.10)(jsdom@23.2.0)(terser@5.19.1) apps/tlon-mobile: dependencies: @@ -229,7 +229,7 @@ importers: version: 4.17.21 posthog-react-native: specifier: ^2.7.1 - version: 2.11.3(@react-native-async-storage/async-storage@1.21.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0)))(@react-navigation/native@6.1.10(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(expo-application@5.8.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(expo-device@5.9.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(expo-file-system@16.0.9(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(expo-localization@14.8.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(react-native-device-info@10.12.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))) + version: 2.11.3(oi5hx7o6oxzkjy7oghrlxekpbi) react: specifier: ^18.2.0 version: 18.2.0 @@ -305,7 +305,7 @@ importers: devDependencies: '@react-native/metro-config': specifier: ^0.73.5 - version: 0.73.5(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13) + version: 0.73.5(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)) '@tamagui/babel-plugin': specifier: 1.101.3 version: 1.101.3(encoding@0.1.13)(react@18.2.0) @@ -368,7 +368,7 @@ importers: version: 3.4.1 vitest: specifier: ^1.0.4 - version: 1.2.2(@types/node@20.10.8)(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1) + version: 1.2.2(@types/node@20.14.10)(jsdom@23.2.0)(terser@5.19.1) apps/tlon-web: dependencies: @@ -422,13 +422,13 @@ importers: version: 1.101.3(encoding@0.1.13)(react@18.2.0) '@tanstack/react-query': specifier: ^4.28.0 - version: 4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + version: 4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@tanstack/react-query-devtools': specifier: ^4.28.0 - version: 4.29.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 4.29.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tanstack/react-query-persist-client': specifier: ^4.28.0 - version: 4.28.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0)) + version: 4.28.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)) '@tanstack/react-virtual': specifier: ^3.0.0-beta.60 version: 3.0.0-beta.65(react@18.2.0) @@ -644,7 +644,7 @@ importers: version: 18.2.0 react-beautiful-dnd: specifier: ^13.1.1 - version: 13.1.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + version: 13.1.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) react-colorful: specifier: ^5.5.1 version: 5.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -680,7 +680,7 @@ importers: version: https://codeload.github.com/stefkampen/react-oembed-container/tar.gz/802eee0dba7986faa9c931b1c016acba5369d5f9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-qr-code: specifier: ^2.0.12 - version: 2.0.12(react-native-svg@15.0.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react@18.2.0) + version: 2.0.12(react-native-svg@15.0.0(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react@18.2.0) react-router: specifier: ^6.22.1 version: 6.22.2(react@18.2.0) @@ -737,7 +737,7 @@ importers: version: 0.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) vite-plugin-svgr: specifier: ^4.2.0 - version: 4.2.0(rollup@4.13.0)(typescript@5.4.5)(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)) + version: 4.2.0(rollup@2.79.1)(typescript@5.4.5)(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1)) workbox-precaching: specifier: ^6.5.4 version: 6.6.0 @@ -828,10 +828,10 @@ importers: version: 2.0.1 '@vitejs/plugin-basic-ssl': specifier: ^1.1.0 - version: 1.1.0(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)) + version: 1.1.0(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1)) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.2.1(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)) + version: 4.2.1(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1)) '@welldone-software/why-did-you-render': specifier: ^7.0.1 version: 7.0.1(react@18.2.0) @@ -873,7 +873,7 @@ importers: version: 6.1.1 react-cosmos-plugin-vite: specifier: 6.1.1 - version: 6.1.1(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)) + version: 6.1.1(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1)) react-test-renderer: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -882,7 +882,7 @@ importers: version: 4.0.0 rollup-plugin-visualizer: specifier: ^5.6.0 - version: 5.12.0(rollup@4.13.0) + version: 5.12.0(rollup@2.79.1) tailwindcss: specifier: ^3.2.7 version: 3.4.1 @@ -897,13 +897,13 @@ importers: version: 1.1.4(typescript@5.4.5) vite: specifier: ^5.1.6 - version: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + version: 5.1.6(@types/node@20.10.8)(terser@5.19.1) vite-plugin-pwa: specifier: ^0.17.5 - version: 0.17.5(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1))(workbox-build@7.0.0(@types/babel__core@7.20.5))(workbox-window@7.0.0) + version: 0.17.5(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1))(workbox-build@7.0.0(@types/babel__core@7.20.5))(workbox-window@7.0.0) vitest: specifier: ^0.34.1 - version: 0.34.6(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1) + version: 0.34.6(jsdom@23.2.0)(terser@5.19.1) workbox-window: specifier: ^7.0.0 version: 7.0.0 @@ -937,7 +937,7 @@ importers: version: 6.21.0(eslint@8.56.0)(typescript@5.4.5) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.2.1(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)) + version: 4.2.1(vite@5.1.6(@types/node@20.14.10)(terser@5.19.1)) eslint: specifier: ^8.50.0 version: 8.56.0 @@ -949,13 +949,13 @@ importers: version: 5.4.5 vite: specifier: ^5.1.6 - version: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + version: 5.1.6(@types/node@20.14.10)(terser@5.19.1) vite-plugin-singlefile: specifier: ^2.0.1 - version: 2.0.1(rollup@4.13.0)(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)) + version: 2.0.1(rollup@4.13.0)(vite@5.1.6(@types/node@20.14.10)(terser@5.19.1)) vitest: specifier: ^1.0.4 - version: 1.5.0(@types/node@20.10.8)(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1) + version: 1.5.0(@types/node@20.14.10)(jsdom@23.2.0)(terser@5.19.1) packages/shared: dependencies: @@ -1017,13 +1017,13 @@ importers: version: 0.20.17 tsup: specifier: ^8.0.1 - version: 8.0.1(@swc/core@1.4.16(@swc/helpers@0.5.10))(postcss@8.4.35)(typescript@5.4.5) + version: 8.0.1(@swc/core@1.4.16)(postcss@8.4.35)(typescript@5.4.5) typescript: specifier: 5.4.5 version: 5.4.5 vitest: specifier: ^1.4.0 - version: 1.5.0(@types/node@20.10.8)(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1) + version: 1.5.0(@types/node@20.14.10)(jsdom@23.2.0)(terser@5.19.1) packages/ui: dependencies: @@ -1078,6 +1078,9 @@ importers: expo-image-picker: specifier: ~14.7.1 version: 14.7.1(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)) + fuzzy: + specifier: ^0.1.3 + version: 0.1.3 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -5226,6 +5229,9 @@ packages: '@types/node@20.10.8': resolution: {integrity: sha512-f8nQs3cLxbAFc00vEU59yf9UyGUftkPaLGfvbVOIDdx2i1b8epBqj2aNGyP19fiyXWvlmZ7qC1XLjAzw/OKIeA==} + '@types/node@20.14.10': + resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -16167,7 +16173,7 @@ snapshots: - '@babel/preset-env' - supports-color - '@react-native/metro-config@0.73.5(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)': + '@react-native/metro-config@0.73.5(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))': dependencies: '@react-native/js-polyfills': 0.73.1 '@react-native/metro-babel-transformer': 0.73.15(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)) @@ -16176,10 +16182,7 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' - - bufferutil - - encoding - supports-color - - utf-8-validate '@react-native/normalize-color@2.1.0': {} @@ -16193,6 +16196,13 @@ snapshots: nullthrows: 1.1.1 react-native: 0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0) + '@react-native/virtualized-lists@0.73.4(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react-native: 0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0) + optional: true + '@react-navigation/bottom-tabs@6.5.12(@react-navigation/native@6.1.10(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-safe-area-context@4.8.2(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-screens@3.29.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: '@react-navigation/elements': 1.3.22(@react-navigation/native@6.1.10(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-safe-area-context@4.8.2(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) @@ -16315,13 +16325,13 @@ snapshots: picomatch: 2.3.1 rollup: 2.79.1 - '@rollup/pluginutils@5.1.0(rollup@4.13.0)': + '@rollup/pluginutils@5.1.0(rollup@2.79.1)': dependencies: '@types/estree': 1.0.5 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 4.13.0 + rollup: 2.79.1 '@rollup/rollup-android-arm-eabi@4.13.0': optional: true @@ -17406,28 +17416,28 @@ snapshots: dependencies: '@tanstack/query-core': 4.27.0 - '@tanstack/react-query-devtools@4.29.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@tanstack/react-query-devtools@4.29.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@tanstack/match-sorter-utils': 8.8.4 - '@tanstack/react-query': 4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + '@tanstack/react-query': 4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) superjson: 1.12.2 use-sync-external-store: 1.2.0(react@18.2.0) - '@tanstack/react-query-persist-client@4.28.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))': + '@tanstack/react-query-persist-client@4.28.0(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))': dependencies: '@tanstack/query-persist-client-core': 4.27.0 - '@tanstack/react-query': 4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + '@tanstack/react-query': 4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) - '@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': + '@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: '@tanstack/query-core': 4.36.1 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) optionalDependencies: react-dom: 18.2.0(react@18.2.0) - react-native: 0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0) + react-native: 0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0) '@tanstack/react-query@5.32.1(react@18.2.0)': dependencies: @@ -18070,6 +18080,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.14.10': + dependencies: + undici-types: 5.26.5 + '@types/normalize-package-data@2.4.4': {} '@types/object.omit@3.0.0': {} @@ -18127,7 +18141,7 @@ snapshots: '@types/resolve@1.17.1': dependencies: - '@types/node': 20.10.8 + '@types/node': 20.14.10 '@types/scheduler@0.16.2': {} @@ -18402,18 +18416,29 @@ snapshots: graphql: 15.8.0 wonka: 4.0.15 - '@vitejs/plugin-basic-ssl@1.1.0(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1))': + '@vitejs/plugin-basic-ssl@1.1.0(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1))': dependencies: - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) + + '@vitejs/plugin-react@4.2.1(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1))': + dependencies: + '@babel/core': 7.23.7 + '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.7) + '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.0 + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) + transitivePeerDependencies: + - supports-color - '@vitejs/plugin-react@4.2.1(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1))': + '@vitejs/plugin-react@4.2.1(vite@5.1.6(@types/node@20.14.10)(terser@5.19.1))': dependencies: '@babel/core': 7.23.7 '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.7) '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.14.10)(terser@5.19.1) transitivePeerDependencies: - supports-color @@ -21876,7 +21901,7 @@ snapshots: jest-worker@26.6.2: dependencies: - '@types/node': 20.10.8 + '@types/node': 20.14.10 merge-stream: 2.0.0 supports-color: 7.2.0 @@ -23233,8 +23258,8 @@ snapshots: dependencies: fflate: 0.4.8 - ? posthog-react-native@2.11.3(@react-native-async-storage/async-storage@1.21.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0)))(@react-navigation/native@6.1.10(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(expo-application@5.8.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(expo-device@5.9.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(expo-file-system@16.0.9(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(expo-localization@14.8.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)))(react-native-device-info@10.12.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))) - : optionalDependencies: + posthog-react-native@2.11.3(oi5hx7o6oxzkjy7oghrlxekpbi): + optionalDependencies: '@react-native-async-storage/async-storage': 1.21.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0)) '@react-navigation/native': 6.1.10(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) expo-application: 5.8.3(expo@50.0.6(@babel/core@7.23.7)(@react-native/babel-preset@0.73.21(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7)))(encoding@0.1.13)) @@ -23594,7 +23619,7 @@ snapshots: minimist: 1.2.6 strip-json-comments: 2.0.1 - react-beautiful-dnd@13.1.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0): + react-beautiful-dnd@13.1.1(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.23.8 css-box-model: 1.2.1 @@ -23602,7 +23627,7 @@ snapshots: raf-schd: 4.0.3 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-redux: 7.2.8(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + react-redux: 7.2.8(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) redux: 4.2.0 use-memo-one: 1.1.3(react@18.2.0) transitivePeerDependencies: @@ -23630,11 +23655,11 @@ snapshots: react-cosmos-core: 6.1.1 react-cosmos-renderer: 6.1.1 - react-cosmos-plugin-vite@6.1.1(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)): + react-cosmos-plugin-vite@6.1.1(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1)): dependencies: react-cosmos-core: 6.1.1 react-cosmos-dom: 6.1.1 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) react-cosmos-renderer@6.1.1: dependencies: @@ -23909,6 +23934,14 @@ snapshots: react: 18.2.0 react-native: 0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0) + react-native-svg@15.0.0(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0): + dependencies: + css-select: 5.1.0 + css-tree: 1.1.3 + react: 18.2.0 + react-native: 0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0) + optional: true + react-native-url-polyfill@2.0.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0)): dependencies: react-native: 0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0) @@ -24058,21 +24091,71 @@ snapshots: - supports-color - utf-8-validate + react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native-community/cli': 12.3.2(encoding@0.1.13) + '@react-native-community/cli-platform-android': 12.3.2(encoding@0.1.13) + '@react-native-community/cli-platform-ios': 12.3.2(encoding@0.1.13) + '@react-native/assets-registry': 0.73.1 + '@react-native/codegen': 0.73.3(@babel/preset-env@7.23.7(@babel/core@7.23.7)) + '@react-native/community-cli-plugin': 0.73.16(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13) + '@react-native/gradle-plugin': 0.73.4 + '@react-native/js-polyfills': 0.73.1 + '@react-native/normalize-colors': 0.73.2 + '@react-native/virtualized-lists': 0.73.4(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0)) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + base64-js: 1.5.1 + chalk: 4.1.2 + deprecated-react-native-prop-types: 5.0.0 + event-target-shim: 5.0.1 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + jsc-android: 250231.0.0 + memoize-one: 5.2.1 + metro-runtime: 0.80.5 + metro-source-map: 0.80.5 + mkdirp: 0.5.6 + nullthrows: 1.1.1 + pretty-format: 26.6.2 + promise: 8.3.0 + react: 18.2.0 + react-devtools-core: 4.28.5 + react-refresh: 0.14.0 + react-shallow-renderer: 16.15.0(react@18.2.0) + regenerator-runtime: 0.13.11 + scheduler: 0.24.0-canary-efb381bbf-20230505 + stacktrace-parser: 0.1.10 + whatwg-fetch: 3.6.20 + ws: 6.2.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - utf-8-validate + optional: true + react-oembed-container@https://codeload.github.com/stefkampen/react-oembed-container/tar.gz/802eee0dba7986faa9c931b1c016acba5369d5f9(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: prop-types: 15.8.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-qr-code@2.0.12(react-native-svg@15.0.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react@18.2.0): + react-qr-code@2.0.12(react-native-svg@15.0.0(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react@18.2.0): dependencies: prop-types: 15.8.1 qr.js: 0.0.0 react: 18.2.0 optionalDependencies: - react-native-svg: 15.0.0(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + react-native-svg: 15.0.0(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) - react-redux@7.2.8(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0): + react-redux@7.2.8(react-dom@18.2.0(react@18.2.0))(react-native@0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.23.8 '@types/react-redux': 7.1.24 @@ -24083,7 +24166,7 @@ snapshots: react-is: 17.0.2 optionalDependencies: react-dom: 18.2.0(react@18.2.0) - react-native: 0.73.4(@babel/core@7.23.7)(@babel/preset-env@7.23.7(@babel/core@7.23.7))(encoding@0.1.13)(react@18.2.0) + react-native: 0.73.4(@babel/core@7.23.7)(encoding@0.1.13)(react@18.2.0) react-refresh@0.14.0: {} @@ -24416,14 +24499,14 @@ snapshots: serialize-javascript: 4.0.0 terser: 5.19.1 - rollup-plugin-visualizer@5.12.0(rollup@4.13.0): + rollup-plugin-visualizer@5.12.0(rollup@2.79.1): dependencies: open: 8.4.2 picomatch: 2.3.1 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.13.0 + rollup: 2.79.1 rollup@2.79.1: optionalDependencies: @@ -25347,7 +25430,7 @@ snapshots: tslib@2.6.2: {} - tsup@8.0.1(@swc/core@1.4.16(@swc/helpers@0.5.10))(postcss@8.4.35)(typescript@5.4.5): + tsup@8.0.1(@swc/core@1.4.16)(postcss@8.4.35)(typescript@5.4.5): dependencies: bundle-require: 4.0.2(esbuild@0.19.12) cac: 6.7.14 @@ -25660,14 +25743,14 @@ snapshots: react-dom: 18.2.0(react@18.2.0) redux: 4.2.0 - vite-node@0.34.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1): + vite-node@0.34.6(@types/node@20.10.8)(terser@5.19.1): dependencies: cac: 6.7.14 debug: 4.3.4 mlly: 1.5.0 pathe: 1.1.2 picocolors: 1.0.0 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) transitivePeerDependencies: - '@types/node' - less @@ -25678,13 +25761,13 @@ snapshots: - supports-color - terser - vite-node@1.2.2(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1): + vite-node@1.2.2(@types/node@20.14.10)(terser@5.19.1): dependencies: cac: 6.7.14 debug: 4.3.4 pathe: 1.1.2 picocolors: 1.0.0 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.14.10)(terser@5.19.1) transitivePeerDependencies: - '@types/node' - less @@ -25695,13 +25778,13 @@ snapshots: - supports-color - terser - vite-node@1.5.0(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1): + vite-node@1.5.0(@types/node@20.14.10)(terser@5.19.1): dependencies: cac: 6.7.14 debug: 4.3.4 pathe: 1.1.2 picocolors: 1.0.0 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.14.10)(terser@5.19.1) transitivePeerDependencies: - '@types/node' - less @@ -25712,35 +25795,35 @@ snapshots: - supports-color - terser - vite-plugin-pwa@0.17.5(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1))(workbox-build@7.0.0(@types/babel__core@7.20.5))(workbox-window@7.0.0): + vite-plugin-pwa@0.17.5(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1))(workbox-build@7.0.0(@types/babel__core@7.20.5))(workbox-window@7.0.0): dependencies: debug: 4.3.4 fast-glob: 3.3.2 pretty-bytes: 6.1.1 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) workbox-build: 7.0.0(@types/babel__core@7.20.5) workbox-window: 7.0.0 transitivePeerDependencies: - supports-color - vite-plugin-singlefile@2.0.1(rollup@4.13.0)(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)): + vite-plugin-singlefile@2.0.1(rollup@4.13.0)(vite@5.1.6(@types/node@20.14.10)(terser@5.19.1)): dependencies: micromatch: 4.0.5 rollup: 4.13.0 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.14.10)(terser@5.19.1) - vite-plugin-svgr@4.2.0(rollup@4.13.0)(typescript@5.4.5)(vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1)): + vite-plugin-svgr@4.2.0(rollup@2.79.1)(typescript@5.4.5)(vite@5.1.6(@types/node@20.10.8)(terser@5.19.1)): dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.13.0) + '@rollup/pluginutils': 5.1.0(rollup@2.79.1) '@svgr/core': 8.1.0(typescript@5.4.5) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.4.5)) - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1): + vite@5.1.6(@types/node@20.10.8)(terser@5.19.1): dependencies: esbuild: 0.19.12 postcss: 8.4.35 @@ -25748,10 +25831,19 @@ snapshots: optionalDependencies: '@types/node': 20.10.8 fsevents: 2.3.3 - lightningcss: 1.19.0 terser: 5.19.1 - vitest@0.34.6(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1): + vite@5.1.6(@types/node@20.14.10)(terser@5.19.1): + dependencies: + esbuild: 0.19.12 + postcss: 8.4.35 + rollup: 4.13.0 + optionalDependencies: + '@types/node': 20.14.10 + fsevents: 2.3.3 + terser: 5.19.1 + + vitest@0.34.6(jsdom@23.2.0)(terser@5.19.1): dependencies: '@types/chai': 4.3.11 '@types/chai-subset': 1.3.5 @@ -25774,8 +25866,8 @@ snapshots: strip-literal: 1.3.0 tinybench: 2.6.0 tinypool: 0.7.0 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) - vite-node: 0.34.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.10.8)(terser@5.19.1) + vite-node: 0.34.6(@types/node@20.10.8)(terser@5.19.1) why-is-node-running: 2.2.2 optionalDependencies: jsdom: 23.2.0 @@ -25788,7 +25880,7 @@ snapshots: - supports-color - terser - vitest@1.2.2(@types/node@20.10.8)(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1): + vitest@1.2.2(@types/node@20.14.10)(jsdom@23.2.0)(terser@5.19.1): dependencies: '@vitest/expect': 1.2.2 '@vitest/runner': 1.2.2 @@ -25808,11 +25900,11 @@ snapshots: strip-literal: 1.3.0 tinybench: 2.6.0 tinypool: 0.8.2 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) - vite-node: 1.2.2(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.14.10)(terser@5.19.1) + vite-node: 1.2.2(@types/node@20.14.10)(terser@5.19.1) why-is-node-running: 2.2.2 optionalDependencies: - '@types/node': 20.10.8 + '@types/node': 20.14.10 jsdom: 23.2.0 transitivePeerDependencies: - less @@ -25823,7 +25915,7 @@ snapshots: - supports-color - terser - vitest@1.5.0(@types/node@20.10.8)(jsdom@23.2.0)(lightningcss@1.19.0)(terser@5.19.1): + vitest@1.5.0(@types/node@20.14.10)(jsdom@23.2.0)(terser@5.19.1): dependencies: '@vitest/expect': 1.5.0 '@vitest/runner': 1.5.0 @@ -25842,11 +25934,11 @@ snapshots: strip-literal: 2.1.0 tinybench: 2.6.0 tinypool: 0.8.4 - vite: 5.1.6(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) - vite-node: 1.5.0(@types/node@20.10.8)(lightningcss@1.19.0)(terser@5.19.1) + vite: 5.1.6(@types/node@20.14.10)(terser@5.19.1) + vite-node: 1.5.0(@types/node@20.14.10)(terser@5.19.1) why-is-node-running: 2.2.2 optionalDependencies: - '@types/node': 20.10.8 + '@types/node': 20.14.10 jsdom: 23.2.0 transitivePeerDependencies: - less