From 5e7f094cde08bff7f592d375dca41e692a14ed74 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Wed, 26 Jun 2024 13:55:18 -0500 Subject: [PATCH 01/19] native: add ability to edit group metadata --- apps/tlon-mobile/src/hooks/useImageUpload.ts | 11 + .../screens/GroupSettings/GroupMetaScreen.tsx | 22 +- .../screens/GroupSettings/useGroupContext.ts | 13 +- packages/shared/src/api/groupsApi.ts | 60 ++++ packages/shared/src/store/groupActions.ts | 43 +++ packages/ui/package.json | 1 + packages/ui/src/components/Button.tsx | 32 +- .../src/components/Channel/ChannelHeader.tsx | 66 +--- packages/ui/src/components/DeleteSheet.tsx | 35 ++ .../src/components/FeatureFlagScreenView.tsx | 2 +- packages/ui/src/components/FormInput.tsx | 53 +++ packages/ui/src/components/GenericHeader.tsx | 65 ++++ .../components/GroupChannelsScreenView.tsx | 2 +- packages/ui/src/components/GroupIcon.tsx | 90 ++++++ .../ui/src/components/GroupMetaScreenView.tsx | 306 ++++++++++++++++++ packages/ui/src/index.ts | 1 + pnpm-lock.yaml | 13 + 17 files changed, 739 insertions(+), 76 deletions(-) create mode 100644 packages/ui/src/components/DeleteSheet.tsx create mode 100644 packages/ui/src/components/FormInput.tsx create mode 100644 packages/ui/src/components/GenericHeader.tsx create mode 100644 packages/ui/src/components/GroupIcon.tsx create mode 100644 packages/ui/src/components/GroupMetaScreenView.tsx diff --git a/apps/tlon-mobile/src/hooks/useImageUpload.ts b/apps/tlon-mobile/src/hooks/useImageUpload.ts index bf940b3012..d93a5a1305 100644 --- a/apps/tlon-mobile/src/hooks/useImageUpload.ts +++ b/apps/tlon-mobile/src/hooks/useImageUpload.ts @@ -36,6 +36,17 @@ export function useImageUpload(props: UploadParams): UploadInfo { uploader?.clear(); }, [uploader]); + useEffect(() => { + // if the most recent file is null, but we have an uploaded image, it means + // that the image was successfully uploaded and we should reset the state + // This is a bit of a hack to get around the fact that + // some race condition can cause the uploadedImage to stick around after + // resetImageAttachment is called from a consumer of the hook + if (mostRecentFile === null && uploadedImage !== null) { + resetImageAttachment(); + } + }, [mostRecentFile, uploadedImage, resetImageAttachment]); + useEffect(() => { const getResizedImage = async (uri: string) => { const manipulated = await resizeImage(uri); diff --git a/apps/tlon-mobile/src/screens/GroupSettings/GroupMetaScreen.tsx b/apps/tlon-mobile/src/screens/GroupSettings/GroupMetaScreen.tsx index c8c199c7d7..3986e5cdcb 100644 --- a/apps/tlon-mobile/src/screens/GroupSettings/GroupMetaScreen.tsx +++ b/apps/tlon-mobile/src/screens/GroupSettings/GroupMetaScreen.tsx @@ -1,6 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { Text } from '@tloncorp/ui'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { GroupMetaScreenView } from '@tloncorp/ui'; import { GroupSettingsStackParamList } from '../../types'; import { useGroupContext } from './useGroupContext'; @@ -13,13 +12,24 @@ type GroupMetaScreenProps = NativeStackScreenProps< export function GroupMetaScreen(props: GroupMetaScreenProps) { const { groupId } = props.route.params; - const { group, currentUserIsAdmin, setGroupMetadata } = useGroupContext({ + const { + group, + currentUserIsAdmin, + setGroupMetadata, + uploadInfo, + deleteGroup, + } = useGroupContext({ groupId, }); return ( - - GroupMeta - + ); } diff --git a/apps/tlon-mobile/src/screens/GroupSettings/useGroupContext.ts b/apps/tlon-mobile/src/screens/GroupSettings/useGroupContext.ts index eb4b6be50f..c38cef0b34 100644 --- a/apps/tlon-mobile/src/screens/GroupSettings/useGroupContext.ts +++ b/apps/tlon-mobile/src/screens/GroupSettings/useGroupContext.ts @@ -4,6 +4,7 @@ import * as store from '@tloncorp/shared/dist/store'; import { useCallback, useEffect, useMemo } from 'react'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; +import { useImageUpload } from '../../hooks/useImageUpload'; export const useGroupContext = ({ groupId }: { groupId: string }) => { const currentUserId = useCurrentUserId(); @@ -13,6 +14,10 @@ export const useGroupContext = ({ groupId }: { groupId: string }) => { const group = groupQuery.data; + const uploadInfo = useImageUpload({ + uploaderKey: `group-${groupId}`, + }); + const currentUserIsAdmin = useMemo(() => { return group?.members.some( (member) => @@ -49,7 +54,10 @@ export const useGroupContext = ({ groupId }: { groupId: string }) => { const setGroupMetadata = useCallback( async (metadata: db.ClientMeta) => { if (group) { - // await store.updateGroupMetadata(group.id, metadata); + await store.updateGroup({ + ...group, + ...metadata, + }); } }, [group] @@ -67,7 +75,7 @@ export const useGroupContext = ({ groupId }: { groupId: string }) => { const deleteGroup = useCallback(async () => { if (group) { - // await store.deleteGroup(group.id); + await store.deleteGroup(group); } }, [group]); @@ -229,6 +237,7 @@ export const useGroupContext = ({ groupId }: { groupId: string }) => { return { group, + uploadInfo, groupMembers, groupInvites, groupChannels, diff --git a/packages/shared/src/api/groupsApi.ts b/packages/shared/src/api/groupsApi.ts index a6eecadc5d..5460949e83 100644 --- a/packages/shared/src/api/groupsApi.ts +++ b/packages/shared/src/api/groupsApi.ts @@ -187,6 +187,66 @@ export const getGroups = async ( return toClientGroups(groupData, true); }; +export const updateGroup = async ({ + groupId, + meta, +}: { + groupId: string; + meta: ub.GroupMeta; +}) => { + return await trackedPoke( + { + app: 'groups', + mark: 'group-action-3', + json: { + flag: groupId, + update: { + time: '', + diff: { + meta, + }, + }, + }, + }, + { app: 'groups', path: '/groups/ui' }, + (event) => { + if (!('update' in event)) { + return false; + } + + const { update } = event; + return 'meta' in update.diff && event.flag === groupId; + } + ); +}; + +export const deleteGroup = async (groupId: string) => { + return await trackedPoke( + { + app: 'groups', + mark: 'group-action-3', + json: { + flag: groupId, + update: { + time: '', + diff: { + del: null, + }, + }, + }, + }, + { app: 'groups', path: '/groups/ui' }, + (event) => { + if (!('update' in event)) { + return false; + } + + const { update } = event; + return 'del' in update.diff && event.flag === groupId; + } + ); +}; + export type GroupDelete = { type: 'deleteGroup'; groupId: string; diff --git a/packages/shared/src/store/groupActions.ts b/packages/shared/src/store/groupActions.ts index 1a75c14b16..bb49e40df0 100644 --- a/packages/shared/src/store/groupActions.ts +++ b/packages/shared/src/store/groupActions.ts @@ -139,3 +139,46 @@ export async function markGroupVisited(group: db.Group) { logger.log('marking new group as visited', group.id); await db.updateGroup({ id: group.id, isNew: false }); } + +export async function updateGroup(group: db.Group) { + logger.log('updating group', group.id); + + const existingGroup = await db.getGroup({ id: group.id }); + + // optimistic update + await db.updateGroup(group); + + try { + await api.updateGroup({ + groupId: group.id, + meta: { + title: group.title ?? '', + description: group.description ?? '', + cover: group.coverImage ?? group.coverImageColor ?? '', + image: group.iconImage ?? group.iconImageColor ?? '', + }, + }); + } catch (e) { + console.error('Failed to update group', e); + // rollback optimistic update + await db.updateGroup({ + id: group.id, + ...existingGroup, + }); + } +} + +export async function deleteGroup(group: db.Group) { + logger.log('deleting group', group.id); + + // optimistic update + await db.deleteGroup(group.id); + + try { + await api.deleteGroup(group.id); + } catch (e) { + console.error('Failed to delete group', e); + // rollback optimistic update + await db.insertGroups({ groups: [group] }); + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 79749a5e26..693cfad17d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,6 +39,7 @@ "expo-image-picker": "~14.7.1", "lodash": "^4.17.21", "moti": "^0.28.1", + "react-hook-form": "^7.52.0", "react-native-context-menu-view": "^1.15.0", "react-native-reanimated": "^3.8.1", "react-native-safe-area-context": "^4.9.0", diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 44c6a9acfd..0e571e82c9 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -17,6 +17,7 @@ export const ButtonContext = createStyledContext<{ color: ThemeTokens; minimal: boolean; hero: boolean; + heroDestructive: boolean; secondary: boolean; disabled: boolean; onPress?: () => void; @@ -25,6 +26,7 @@ export const ButtonContext = createStyledContext<{ color: '$primaryText', minimal: false, hero: false, + heroDestructive: false, secondary: false, disabled: false, }); @@ -85,6 +87,19 @@ export const ButtonFrame = styled(Stack, { }, }, } as const, + heroDestructive: { + true: { + backgroundColor: '$red', + padding: '$xl', + borderWidth: 0, + pressStyle: { + backgroundColor: '$redSoft', + }, + disabledStyle: { + backgroundColor: '$gray600', + }, + }, + } as const, secondary: { true: { backgroundColor: '$border', @@ -133,6 +148,14 @@ export const ButtonText = styled(Text, { fontWeight: '500', }, }, + heroDestructive: { + true: { + color: '$white', + width: '100%', + textAlign: 'center', + fontWeight: '500', + }, + }, secondary: { true: { width: '100%', @@ -150,14 +173,19 @@ export const ButtonText = styled(Text, { }); const ButtonIcon = (props: { color?: ColorTokens; children: any }) => { - const { size, color, hero } = useContext(ButtonContext.context); + const { size, color, hero, heroDestructive } = useContext( + ButtonContext.context + ); const smaller = getSize(size, { shift: -1, }); const theme = useTheme(); return cloneElement(props.children, { size: smaller.val, - color: props.color ?? color ?? (hero ? '$white' : '$primaryText'), + color: + props.color ?? + color ?? + (hero || heroDestructive ? '$white' : '$primaryText'), }); }; diff --git a/packages/ui/src/components/Channel/ChannelHeader.tsx b/packages/ui/src/components/Channel/ChannelHeader.tsx index ee09a9c029..c8bbda9d5b 100644 --- a/packages/ui/src/components/Channel/ChannelHeader.tsx +++ b/packages/ui/src/components/Channel/ChannelHeader.tsx @@ -1,75 +1,13 @@ import * as db from '@tloncorp/shared/dist/db'; import { useMemo, useState } from 'react'; -import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ChevronLeft, Dots, Search } from '../../assets/icons'; -import { SizableText, View, XStack } from '../../core'; +import { Dots, Search } from '../../assets/icons'; import { ActionSheet } from '../ActionSheet'; import { getPostActions } from '../ChatMessage/ChatMessageActions/MessageActions'; +import { GenericHeader } from '../GenericHeader'; import { IconButton } from '../IconButton'; import { BaubleHeader } from './BaubleHeader'; -// TODO: break this out, use for all headers. -export function GenericHeader({ - title, - goBack, - showSpinner, - rightContent, -}: { - title?: string; - goBack?: () => void; - showSpinner?: boolean; - rightContent?: React.ReactNode; -}) { - const insets = useSafeAreaInsets(); - - return ( - - - - {goBack && ( - - - - )} - - - {showSpinner ? 'Loading…' : title} - - - - - {rightContent} - - - - ); -} - export function ChannelHeader({ title, mode = 'default', diff --git a/packages/ui/src/components/DeleteSheet.tsx b/packages/ui/src/components/DeleteSheet.tsx new file mode 100644 index 0000000000..1d44e17c94 --- /dev/null +++ b/packages/ui/src/components/DeleteSheet.tsx @@ -0,0 +1,35 @@ +import { Text } from '../core'; +import { ActionSheet } from './ActionSheet'; + +export function DeleteSheet({ + title, + itemTypeDescription, + open, + onOpenChange, + deleteAction, +}: { + title: string; + itemTypeDescription: string; + open: boolean; + onOpenChange: (show: boolean) => void; + deleteAction: () => void; +}) { + return ( + + Delete {title} + + + Are you sure you want to delete {title}? This action cannot be undone. + + + + + Delete {itemTypeDescription} + + + onOpenChange(false)}> + Cancel + + + ); +} diff --git a/packages/ui/src/components/FeatureFlagScreenView.tsx b/packages/ui/src/components/FeatureFlagScreenView.tsx index 04387c6f3e..be06830d07 100644 --- a/packages/ui/src/components/FeatureFlagScreenView.tsx +++ b/packages/ui/src/components/FeatureFlagScreenView.tsx @@ -2,7 +2,7 @@ import { Switch } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView, SizableText, View, XStack } from '../core'; -import { GenericHeader } from './Channel/ChannelHeader'; +import { GenericHeader } from './GenericHeader'; export function FeatureFlagScreenView({ features, diff --git a/packages/ui/src/components/FormInput.tsx b/packages/ui/src/components/FormInput.tsx new file mode 100644 index 0000000000..8549aef5d0 --- /dev/null +++ b/packages/ui/src/components/FormInput.tsx @@ -0,0 +1,53 @@ +import { + Control, + Controller, + DeepMap, + FieldError, + RegisterOptions, +} from 'react-hook-form'; + +import { Text, View, YStack } from '../core'; +import { Input } from './Input'; + +export function FormInput({ + name, + label, + control, + errors, + rules, + placeholder, +}: { + name: string; + label: string; + control: Control; + errors: DeepMap; + rules?: Omit< + RegisterOptions, + 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled' + >; + placeholder?: string; +}) { + return ( + + ( + + + + + + )} + /> + {errors[name] && {errors[name].message}} + + ); +} diff --git a/packages/ui/src/components/GenericHeader.tsx b/packages/ui/src/components/GenericHeader.tsx new file mode 100644 index 0000000000..0f3e448e07 --- /dev/null +++ b/packages/ui/src/components/GenericHeader.tsx @@ -0,0 +1,65 @@ +import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ChevronLeft } from '../assets/icons'; +import { SizableText, View, XStack } from '../core'; +import { IconButton } from './IconButton'; + +export function GenericHeader({ + title, + goBack, + showSpinner, + rightContent, +}: { + title?: string; + goBack?: () => void; + showSpinner?: boolean; + rightContent?: React.ReactNode; +}) { + const insets = useSafeAreaInsets(); + + return ( + + + + {goBack && ( + + + + )} + + + {showSpinner ? 'Loading…' : title} + + + + + {rightContent} + + + + ); +} diff --git a/packages/ui/src/components/GroupChannelsScreenView.tsx b/packages/ui/src/components/GroupChannelsScreenView.tsx index 42e19dbf5e..3ec4821dea 100644 --- a/packages/ui/src/components/GroupChannelsScreenView.tsx +++ b/packages/ui/src/components/GroupChannelsScreenView.tsx @@ -5,8 +5,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView, View } from '../core'; import { ActionSheet } from './ActionSheet'; import { Button } from './Button'; -import { GenericHeader } from './Channel/ChannelHeader'; import ChannelNavSections from './ChannelNavSections'; +import { GenericHeader } from './GenericHeader'; import { Icon } from './Icon'; const ChannelSortOptions = ({ diff --git a/packages/ui/src/components/GroupIcon.tsx b/packages/ui/src/components/GroupIcon.tsx new file mode 100644 index 0000000000..81abbff341 --- /dev/null +++ b/packages/ui/src/components/GroupIcon.tsx @@ -0,0 +1,90 @@ +import * as db from '@tloncorp/shared/dist/db'; +import { PropsWithChildren } from 'react'; + +import { + FontSizeTokens, + Image, + RadiusTokens, + SizeTokens, + Text, + View, +} from '../core'; +import { getBackgroundColor } from '../utils/colorUtils'; + +export function GroupIconContainer({ + children, + backgroundColor, + size, + borderRadius, +}: PropsWithChildren<{ + backgroundColor: string; + size: SizeTokens; + borderRadius: RadiusTokens; +}>) { + return ( + + {children} + + ); +} + +export function GroupIcon({ + group, + size = '$4xl', + fontSize = '$m', + borderRadius = '$s', +}: { + group: db.Group; + size?: SizeTokens; + fontSize?: FontSizeTokens; + borderRadius?: RadiusTokens; +}) { + const colors = { backgroundColor: '$secondaryBackground' }; + const fallbackText = group.title ?? group.id; + const backgroundColor = getBackgroundColor({ + disableAvatars: false, + colors, + model: group, + }); + const imageUrl = group.iconImage ? group.iconImage : undefined; + + if (imageUrl) { + return ( + + + + ); + } + + return ( + + + + {fallbackText.slice(0, 1).toUpperCase()} + + + + ); +} diff --git a/packages/ui/src/components/GroupMetaScreenView.tsx b/packages/ui/src/components/GroupMetaScreenView.tsx new file mode 100644 index 0000000000..a8420236ca --- /dev/null +++ b/packages/ui/src/components/GroupMetaScreenView.tsx @@ -0,0 +1,306 @@ +import { + MessageAttachments, + UploadInfo, + UploadedFile, +} 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 { useForm } from 'react-hook-form'; +import { getTokenValue } from 'tamagui'; + +import { Text, View, YStack } from '../core'; +import AttachmentSheet from './AttachmentSheet'; +import { Button } from './Button'; +import { DeleteSheet } from './DeleteSheet'; +import { FormInput } from './FormInput'; +import { GenericHeader } from './GenericHeader'; +import { GroupIcon } from './GroupIcon'; +import KeyboardAvoidingView from './KeyboardAvoidingView'; +import { LoadingSpinner } from './LoadingSpinner'; +import Pressable from './Pressable'; + +interface GroupMetaScreenViewProps { + group: db.Group | null; + deleteGroup: () => void; + // leaving this prop here in case it's needed later + // no non-admin should be able to access this screen anyway + currentUserIsAdmin: boolean; + setGroupMetadata: (metadata: db.ClientMeta) => void; + goBack: () => void; + uploadInfo: UploadInfo; +} + +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, + deleteGroup, + goBack, + uploadInfo, +}: 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: { + title: group?.title ?? '', + description: group?.description ?? '', + coverImage: group?.coverImage ?? '', + iconImage: group?.iconImage ?? '', + }, + }); + + 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; + description: string; + coverImage: string; + iconImage: string; + }) => { + setGroupMetadata({ + title: data.title, + description: data.description, + coverImage: data?.coverImage, + iconImage: data?.iconImage, + }); + + goBack(); + }, + [setGroupMetadata, goBack] + ); + + if (!group) { + return ; + } + + return ( + + + } + /> + + + {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'} + /> + + )} + + + + + + + + + { + uploadInfo.setAttachments(attachments); + }} + /> + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 086448d1f1..2ed027093a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,6 +1,7 @@ export * from './core'; export * from './utils'; export * from './components/PostScreenView'; +export * from './components/GroupMetaScreenView'; export * from './components/ImageViewerScreenView'; export * from './components/ProfileScreenView'; export * from './components/Activity/ActivityScreenView'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1982c72566..93923f5ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1087,6 +1087,9 @@ importers: react: specifier: '*' version: 18.2.0 + react-hook-form: + specifier: ^7.52.0 + version: 7.52.0(react@18.2.0) react-native-context-menu-view: specifier: ^1.15.0 version: 1.15.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) @@ -10128,6 +10131,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 + react-hook-form@7.52.0: + resolution: {integrity: sha512-mJX506Xc6mirzLsmXUJyqlAI3Kj9Ph2RhplYhUVffeOQSnubK2uVqBFOBJmvKikvbFV91pxVXmDiR+QMF19x6A==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-image-size@2.0.0: resolution: {integrity: sha512-6wjalqkkRKnU8VslTOUPooP954lvD9G7zSo2zu9S3vvboN4WRDfFmnfm291Pl5wcXroobYrcVpywP8ijshMmeg==} peerDependencies: @@ -23849,6 +23858,10 @@ snapshots: dependencies: react: 18.2.0 + react-hook-form@7.52.0(react@18.2.0): + dependencies: + react: 18.2.0 + react-image-size@2.0.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 From 5af8ef1260cdee7d112ba37a8439fded0bc1fce0 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Wed, 26 Jun 2024 14:07:09 -0500 Subject: [PATCH 02/19] native: add GroupMetaScreen fixture --- apps/tlon-mobile/cosmos.imports.ts | 62 ++++++++++--------- .../src/fixtures/GroupMetaScreen.fixture.tsx | 27 ++++++++ 2 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 apps/tlon-mobile/src/fixtures/GroupMetaScreen.fixture.tsx diff --git a/apps/tlon-mobile/cosmos.imports.ts b/apps/tlon-mobile/cosmos.imports.ts index 2f1cf4d24f..486cd907cf 100644 --- a/apps/tlon-mobile/cosmos.imports.ts +++ b/apps/tlon-mobile/cosmos.imports.ts @@ -3,21 +3,22 @@ import { RendererConfig, UserModuleWrappers } from 'react-cosmos-core'; import * as fixture0 from './src/App.fixture'; -import * as fixture30 from './src/fixtures/ActionSheet.fixture'; -import * as fixture29 from './src/fixtures/AudioEmbed.fixture'; -import * as fixture28 from './src/fixtures/BlockSectionList.fixture'; -import * as fixture27 from './src/fixtures/Button.fixture'; -import * as fixture26 from './src/fixtures/Channel.fixture'; -import * as fixture25 from './src/fixtures/ChannelDivider.fixture'; -import * as fixture24 from './src/fixtures/ChannelHeader.fixture'; -import * as fixture23 from './src/fixtures/ChannelSwitcherSheet.fixture'; -import * as fixture22 from './src/fixtures/ChatMessage.fixture'; -import * as fixture21 from './src/fixtures/ChatReference.fixture'; -import * as fixture20 from './src/fixtures/ContactList.fixture'; -import * as fixture19 from './src/fixtures/DetailView.fixture'; -import * as fixture18 from './src/fixtures/GalleryPost.fixture'; -import * as fixture17 from './src/fixtures/GroupList.fixture'; -import * as fixture16 from './src/fixtures/GroupListItem.fixture'; +import * as fixture31 from './src/fixtures/ActionSheet.fixture'; +import * as fixture30 from './src/fixtures/AudioEmbed.fixture'; +import * as fixture29 from './src/fixtures/BlockSectionList.fixture'; +import * as fixture28 from './src/fixtures/Button.fixture'; +import * as fixture27 from './src/fixtures/Channel.fixture'; +import * as fixture26 from './src/fixtures/ChannelDivider.fixture'; +import * as fixture25 from './src/fixtures/ChannelHeader.fixture'; +import * as fixture24 from './src/fixtures/ChannelSwitcherSheet.fixture'; +import * as fixture23 from './src/fixtures/ChatMessage.fixture'; +import * as fixture22 from './src/fixtures/ChatReference.fixture'; +import * as fixture21 from './src/fixtures/ContactList.fixture'; +import * as fixture20 from './src/fixtures/DetailView.fixture'; +import * as fixture19 from './src/fixtures/GalleryPost.fixture'; +import * as fixture18 from './src/fixtures/GroupList.fixture'; +import * as fixture17 from './src/fixtures/GroupListItem.fixture'; +import * as fixture16 from './src/fixtures/GroupMetaScreen.fixture'; import * as fixture15 from './src/fixtures/HeaderButton.fixture'; import * as fixture14 from './src/fixtures/ImageViewer.fixture'; import * as fixture13 from './src/fixtures/Input.fixture'; @@ -57,21 +58,22 @@ const fixtures = { 'src/fixtures/Input.fixture.tsx': { module: fixture13 }, 'src/fixtures/ImageViewer.fixture.tsx': { module: fixture14 }, 'src/fixtures/HeaderButton.fixture.tsx': { module: fixture15 }, - 'src/fixtures/GroupListItem.fixture.tsx': { module: fixture16 }, - 'src/fixtures/GroupList.fixture.tsx': { module: fixture17 }, - 'src/fixtures/GalleryPost.fixture.tsx': { module: fixture18 }, - 'src/fixtures/DetailView.fixture.tsx': { module: fixture19 }, - 'src/fixtures/ContactList.fixture.tsx': { module: fixture20 }, - 'src/fixtures/ChatReference.fixture.tsx': { module: fixture21 }, - 'src/fixtures/ChatMessage.fixture.tsx': { module: fixture22 }, - 'src/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture23 }, - 'src/fixtures/ChannelHeader.fixture.tsx': { module: fixture24 }, - 'src/fixtures/ChannelDivider.fixture.tsx': { module: fixture25 }, - 'src/fixtures/Channel.fixture.tsx': { module: fixture26 }, - 'src/fixtures/Button.fixture.tsx': { module: fixture27 }, - 'src/fixtures/BlockSectionList.fixture.tsx': { module: fixture28 }, - 'src/fixtures/AudioEmbed.fixture.tsx': { module: fixture29 }, - 'src/fixtures/ActionSheet.fixture.tsx': { module: fixture30 }, + 'src/fixtures/GroupMetaScreen.fixture.tsx': { module: fixture16 }, + 'src/fixtures/GroupListItem.fixture.tsx': { module: fixture17 }, + 'src/fixtures/GroupList.fixture.tsx': { module: fixture18 }, + 'src/fixtures/GalleryPost.fixture.tsx': { module: fixture19 }, + 'src/fixtures/DetailView.fixture.tsx': { module: fixture20 }, + 'src/fixtures/ContactList.fixture.tsx': { module: fixture21 }, + 'src/fixtures/ChatReference.fixture.tsx': { module: fixture22 }, + 'src/fixtures/ChatMessage.fixture.tsx': { module: fixture23 }, + 'src/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture24 }, + 'src/fixtures/ChannelHeader.fixture.tsx': { module: fixture25 }, + 'src/fixtures/ChannelDivider.fixture.tsx': { module: fixture26 }, + 'src/fixtures/Channel.fixture.tsx': { module: fixture27 }, + 'src/fixtures/Button.fixture.tsx': { module: fixture28 }, + 'src/fixtures/BlockSectionList.fixture.tsx': { module: fixture29 }, + 'src/fixtures/AudioEmbed.fixture.tsx': { module: fixture30 }, + 'src/fixtures/ActionSheet.fixture.tsx': { module: fixture31 }, }; const decorators = { diff --git a/apps/tlon-mobile/src/fixtures/GroupMetaScreen.fixture.tsx b/apps/tlon-mobile/src/fixtures/GroupMetaScreen.fixture.tsx new file mode 100644 index 0000000000..81902903fb --- /dev/null +++ b/apps/tlon-mobile/src/fixtures/GroupMetaScreen.fixture.tsx @@ -0,0 +1,27 @@ +import { GroupMetaScreenView } from '@tloncorp/ui'; + +import { FixtureWrapper } from './FixtureWrapper'; +import { group } from './fakeData'; + +const GroupMetaScreenFixture = () => { + return ( + + console.log('deleteGroup')} + goBack={() => console.log('goBack')} + setGroupMetadata={() => console.log('setGroupMetadata')} + uploadInfo={{ + imageAttachment: null, + setAttachments: () => console.log('setAttachments'), + resetImageAttachment: () => console.log('resetImageAttachment'), + canUpload: true, + uploading: false, + }} + /> + + ); +}; + +export default GroupMetaScreenFixture; From 3d1be22e58a4a2df3f918bb12a0ea180a41b48e9 Mon Sep 17 00:00:00 2001 From: James Acklin Date: Wed, 26 Jun 2024 18:41:37 -0400 Subject: [PATCH 03/19] packages/shared, native: add firebase performance logging --- .../ios/Landscape.xcodeproj/project.pbxproj | 8 ++++ apps/tlon-mobile/ios/Podfile.lock | 45 +++++++++++++++++++ apps/tlon-mobile/package.json | 1 + .../src/components/AuthenticatedApp.tsx | 2 + apps/tlon-web/test/setup.ts | 10 +++++ packages/shared/src/logic/index.ts | 1 + .../shared/src/logic/performanceLogging.ts | 42 +++++++++++++++++ packages/shared/src/test/setup.ts | 9 ++++ packages/shared/tsup.config.ts | 1 + pnpm-lock.yaml | 18 ++++++++ 10 files changed, 137 insertions(+) create mode 100644 packages/shared/src/logic/performanceLogging.ts diff --git a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj index 9f90aea07a..0398f66b48 100644 --- a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj +++ b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj @@ -686,11 +686,13 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseABTesting/FirebaseABTesting_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCrashlytics/FirebaseCrashlytics_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseRemoteConfig/FirebaseRemoteConfig_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleDataTransport/GoogleDataTransport_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", @@ -705,11 +707,13 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseABTesting_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCrashlytics_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseRemoteConfig_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleDataTransport_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", @@ -788,11 +792,13 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseABTesting/FirebaseABTesting_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCrashlytics/FirebaseCrashlytics_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseRemoteConfig/FirebaseRemoteConfig_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleDataTransport/GoogleDataTransport_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", @@ -807,11 +813,13 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseABTesting_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCrashlytics_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseRemoteConfig_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleDataTransport_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", diff --git a/apps/tlon-mobile/ios/Podfile.lock b/apps/tlon-mobile/ios/Podfile.lock index cdb7935ab1..9cce8333d0 100644 --- a/apps/tlon-mobile/ios/Podfile.lock +++ b/apps/tlon-mobile/ios/Podfile.lock @@ -160,6 +160,11 @@ PODS: - Firebase/Crashlytics (10.24.0): - Firebase/CoreOnly - FirebaseCrashlytics (~> 10.24.0) + - Firebase/Performance (10.24.0): + - Firebase/CoreOnly + - FirebasePerformance (~> 10.24.0) + - FirebaseABTesting (10.28.0): + - FirebaseCore (~> 10.0) - FirebaseCore (10.24.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) @@ -182,6 +187,24 @@ PODS: - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - PromisesObjC (~> 2.1) + - FirebasePerformance (10.24.0): + - FirebaseCore (~> 10.5) + - FirebaseInstallations (~> 10.0) + - FirebaseRemoteConfig (~> 10.0) + - FirebaseSessions (~> 10.5) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/ISASwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseRemoteConfig (10.28.0): + - FirebaseABTesting (~> 10.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - FirebaseRemoteConfigInterop (~> 10.23) + - FirebaseSharedSwift (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - FirebaseRemoteConfigInterop (10.24.0) - FirebaseSessions (10.24.0): - FirebaseCore (~> 10.5) @@ -191,6 +214,7 @@ PODS: - GoogleUtilities/Environment (~> 7.10) - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesSwift (~> 2.1) + - FirebaseSharedSwift (10.28.0) - fmt (6.2.1) - glog (0.3.5) - GoogleDataTransport (9.4.1): @@ -200,9 +224,14 @@ PODS: - GoogleUtilities/Environment (7.13.0): - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/ISASwizzler (7.13.0): + - GoogleUtilities/Privacy - GoogleUtilities/Logger (7.13.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy - "GoogleUtilities/NSData+zlib (7.13.0)": - GoogleUtilities/Privacy - GoogleUtilities/Privacy (7.13.0) @@ -1320,6 +1349,10 @@ PODS: - FirebaseCoreExtension - React-Core - RNFBApp + - RNFBPerf (19.2.2): + - Firebase/Performance (= 10.24.0) + - React-Core + - RNFBApp - RNGestureHandler (2.14.1): - glog - RCT-Folly (= 2022.05.16.00) @@ -1453,6 +1486,7 @@ DEPENDENCIES: - RNDeviceInfo (from `../../../node_modules/react-native-device-info`) - "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)" - "RNFBCrashlytics (from `../../../node_modules/@react-native-firebase/crashlytics`)" + - "RNFBPerf (from `../../../node_modules/@react-native-firebase/perf`)" - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) - RNReanimated (from `../../../node_modules/react-native-reanimated`) - RNScreens (from `../../../node_modules/react-native-screens`) @@ -1465,13 +1499,17 @@ SPEC REPOS: trunk: - BranchSDK - Firebase + - FirebaseABTesting - FirebaseCore - FirebaseCoreExtension - FirebaseCoreInternal - FirebaseCrashlytics - FirebaseInstallations + - FirebasePerformance + - FirebaseRemoteConfig - FirebaseRemoteConfigInterop - FirebaseSessions + - FirebaseSharedSwift - fmt - GoogleDataTransport - GoogleUtilities @@ -1679,6 +1717,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@react-native-firebase/app" RNFBCrashlytics: :path: "../../../node_modules/@react-native-firebase/crashlytics" + RNFBPerf: + :path: "../../../node_modules/@react-native-firebase/perf" RNGestureHandler: :path: "../../../node_modules/react-native-gesture-handler" RNReanimated: @@ -1733,13 +1773,17 @@ SPEC CHECKSUMS: FBLazyVector: 84f6edbe225f38aebd9deaf1540a4160b1f087d7 FBReactNativeSpec: 4b31c1954525bc2e3a5df4cbbd06fc7ae9191b11 Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 + FirebaseABTesting: 589bc28c0ab3e5554336895a34aa262e24276665 FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894 FirebaseCoreExtension: af5fd85e817ea9d19f9a2659a376cf9cf99f03c0 FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af FirebaseCrashlytics: af38ea4adfa606f6e63fcc22091b61e7938fcf66 FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e + FirebasePerformance: 78fed7cf7907f67af3c1e9667d2d1881765f11e2 + FirebaseRemoteConfig: f0879a8dccf4e8905716ed849569130efaeab3e2 FirebaseRemoteConfigInterop: 6c349a466490aeace3ce9c091c86be1730711634 FirebaseSessions: 2651b464e241c93fd44112f995d5ab663c970487 + FirebaseSharedSwift: 48de4aec81a6b79bb30404e5e6db43ea74848fed fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a @@ -1810,6 +1854,7 @@ SPEC CHECKSUMS: RNDeviceInfo: db5c64a060e66e5db3102d041ebe3ef307a85120 RNFBApp: 614f1621b49db54ebd258df8c45427370d8d84a2 RNFBCrashlytics: c39e903af97cb426f36a10d3268fb0623a1ccddf + RNFBPerf: 91a69c5e9c3cefb51928eecc3eff76c616fcbafe RNGestureHandler: 28bdf9a766c081e603120f79e925b72817c751c6 RNReanimated: fb34efce9255966f5d71bd0fc65e14042c4b88a9 RNScreens: 2b73f5eb2ac5d94fbd61fa4be0bfebd345716825 diff --git a/apps/tlon-mobile/package.json b/apps/tlon-mobile/package.json index 684f97714e..41d11e26b8 100644 --- a/apps/tlon-mobile/package.json +++ b/apps/tlon-mobile/package.json @@ -50,6 +50,7 @@ "@react-native-community/netinfo": "11.1.0", "@react-native-firebase/app": "^19.2.2", "@react-native-firebase/crashlytics": "^19.2.2", + "@react-native-firebase/perf": "^19.2.2", "@react-navigation/bottom-tabs": "^6.5.12", "@react-navigation/native": "^6.1.7", "@react-navigation/native-stack": "^6.9.13", diff --git a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx index 5b5170e4d2..e167546389 100644 --- a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx +++ b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx @@ -1,5 +1,6 @@ import crashlytics from '@react-native-firebase/crashlytics'; import { setCrashReporter, sync } from '@tloncorp/shared'; +import { FirebasePerformanceMonitor } from '@tloncorp/shared'; import { QueryClientProvider, queryClient } from '@tloncorp/shared/dist/api'; import * as logic from '@tloncorp/shared/dist/logic'; import { ZStack } from '@tloncorp/ui'; @@ -35,6 +36,7 @@ function AuthenticatedApp({ }); setCrashReporter(crashlytics()); + logic.setPerformanceMonitor(new FirebasePerformanceMonitor()); // TODO: remove, for use in Beta testing only if (currentUserId) { diff --git a/apps/tlon-web/test/setup.ts b/apps/tlon-web/test/setup.ts index 36fee29e42..27c04299eb 100644 --- a/apps/tlon-web/test/setup.ts +++ b/apps/tlon-web/test/setup.ts @@ -6,6 +6,7 @@ import { setupServer } from 'msw/node'; // Required for React 18+ // See: https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html declare global { + // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean; } @@ -23,6 +24,15 @@ vi.mock('posthog-js', () => ({ }, })); +vi.mock('@react-native-firebase/perf', () => ({ + default: () => ({ + newTrace: (traceName: string) => ({ + start: vi.fn(), + stop: vi.fn(), + }), + }), +})); + // Prevent vite from failing when resizeObserver is used Object.defineProperty(global, 'ResizeObserver', { diff --git a/packages/shared/src/logic/index.ts b/packages/shared/src/logic/index.ts index 85f9bb14a4..367083a03b 100644 --- a/packages/shared/src/logic/index.ts +++ b/packages/shared/src/logic/index.ts @@ -4,3 +4,4 @@ export * from './embed'; export * from './types'; export * from './activity'; export * from './errorReporting'; +export * from './performanceLogging'; diff --git a/packages/shared/src/logic/performanceLogging.ts b/packages/shared/src/logic/performanceLogging.ts new file mode 100644 index 0000000000..c20b4a6bff --- /dev/null +++ b/packages/shared/src/logic/performanceLogging.ts @@ -0,0 +1,42 @@ +import perf from '@react-native-firebase/perf'; + +export type PerformanceMonitor = { + startTrace: (traceName: string) => Promise; + stopTrace: (traceName: string) => Promise; +}; + +export class FirebasePerformanceMonitor implements PerformanceMonitor { + private traces: { [key: string]: any } = {}; + + async startTrace(traceName: string) { + if (!this.traces[traceName]) { + this.traces[traceName] = perf().newTrace(traceName); + await this.traces[traceName].start(); + } + } + + async stopTrace(traceName: string) { + if (this.traces[traceName]) { + await this.traces[traceName].stop(); + delete this.traces[traceName]; + } + } +} + +let performanceMonitorInstance: PerformanceMonitor | null = null; + +export function setPerformanceMonitor(client: T) { + performanceMonitorInstance = client; +} + +export const PerformanceMonitor = new Proxy( + {}, + { + get: function (target, prop, receiver) { + if (!performanceMonitorInstance) { + throw new Error('Performance monitor not set!'); + } + return Reflect.get(performanceMonitorInstance, prop, receiver); + }, + } +) as PerformanceMonitor; diff --git a/packages/shared/src/test/setup.ts b/packages/shared/src/test/setup.ts index bdef44a44e..5e480960e1 100644 --- a/packages/shared/src/test/setup.ts +++ b/packages/shared/src/test/setup.ts @@ -19,6 +19,15 @@ vi.mock('@react-native-firebase/crashlytics', () => { }; }); +vi.mock('@react-native-firebase/perf', () => ({ + default: () => ({ + newTrace: (traceName: string) => ({ + start: vi.fn(), + stop: vi.fn(), + }), + }), +})); + export function mockUrbit() { vi.mock('../api/urbit', async () => { return { diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index 4a65ee09a0..e1f8c31432 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner', '@react-native-firebase/crashlytics', + '@react-native-firebase/perf', ], ignoreWatch: ['**/node_modules/**', '**/.git/**'], loader: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a14fddc18b..cd4a7c13af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@react-native-firebase/crashlytics': specifier: ^19.2.2 version: 19.2.2(@react-native-firebase/app@19.2.2(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@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@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-firebase/perf': + specifier: ^19.2.2 + version: 19.2.2(@react-native-firebase/app@19.2.2(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@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@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-navigation/bottom-tabs': specifier: ^6.5.12 version: 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) @@ -3881,6 +3884,15 @@ packages: expo: optional: true + '@react-native-firebase/perf@19.2.2': + resolution: {integrity: sha512-4AZu+1AEYlwdFeop8o5Kac9iIfvd6frLHGa+U7xS+3pKdbVHZlUU2j7eiisOiILORVDaf/lkZ6yWzEDXqBeFjg==} + peerDependencies: + '@react-native-firebase/app': 19.2.2 + expo: '>=47.0.0' + peerDependenciesMeta: + expo: + optional: true + '@react-native-mac/virtualized-lists@0.73.3': resolution: {integrity: sha512-7UcvjGYLIU0s2FzVLUPxHYo68tqtZV6x0AH8B0Hf9mkkpENGdRIKD7wDv0kjb/GkVn+qk94u3u0kQyMNRY9UkQ==} engines: {node: '>=18'} @@ -15916,6 +15928,12 @@ snapshots: optionalDependencies: 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-firebase/perf@19.2.2(@react-native-firebase/app@19.2.2(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@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@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))': + dependencies: + '@react-native-firebase/app': 19.2.2(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@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) + optionalDependencies: + 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-mac/virtualized-lists@0.73.3(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: invariant: 2.2.4 From 5b1f0c1d3c92e4fd8b3474b5f2a158d39997735f Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 26 Jun 2024 23:08:27 +0000 Subject: [PATCH 04/19] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index eb11059810..5dd75a39d1 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-0v7.el4mu.miqcr.rhuad.n8h6f.i0bdf.glob' 0v7.el4mu.miqcr.rhuad.n8h6f.i0bdf] + glob-http+['https://bootstrap.urbit.org/glob-0v2.44g91.9mvon.es09r.5fv53.unahs.glob' 0v2.44g91.9mvon.es09r.5fv53.unahs] base+'groups' version+[6 0 2] website+'https://tlon.io' From 1d4b32d2a0c921e46e33fb40ac30e5a621b25729 Mon Sep 17 00:00:00 2001 From: James Acklin Date: Wed, 26 Jun 2024 19:59:43 -0400 Subject: [PATCH 05/19] packages/shared: conditionally import firebase/perf if running in a non-browser environment; fixes tlon-web vite build error --- packages/shared/src/logic/performanceLogging.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/logic/performanceLogging.ts b/packages/shared/src/logic/performanceLogging.ts index c20b4a6bff..e3a76d5261 100644 --- a/packages/shared/src/logic/performanceLogging.ts +++ b/packages/shared/src/logic/performanceLogging.ts @@ -1,4 +1,4 @@ -import perf from '@react-native-firebase/perf'; +let perfModule: any = null; export type PerformanceMonitor = { startTrace: (traceName: string) => Promise; @@ -8,9 +8,15 @@ export type PerformanceMonitor = { export class FirebasePerformanceMonitor implements PerformanceMonitor { private traces: { [key: string]: any } = {}; + constructor() { + if (typeof window === 'undefined') { + perfModule = require('@react-native-firebase/perf').default; + } + } + async startTrace(traceName: string) { - if (!this.traces[traceName]) { - this.traces[traceName] = perf().newTrace(traceName); + if (!this.traces[traceName] && perfModule) { + this.traces[traceName] = perfModule().newTrace(traceName); await this.traces[traceName].start(); } } From 39b705a70bd49111e9f7b41b48f130262960314f Mon Sep 17 00:00:00 2001 From: James Acklin Date: Thu, 27 Jun 2024 13:58:57 -0400 Subject: [PATCH 06/19] native: move performance logging to tlon-mobile project, remove from shared; initialize directly; add plugins to gradle --- apps/tlon-mobile/android/app/build.gradle | 1 + apps/tlon-mobile/android/build.gradle | 3 +- apps/tlon-mobile/src/App.main.tsx | 3 ++ .../src/components/AuthenticatedApp.tsx | 2 - apps/tlon-web/test/setup.ts | 9 ---- packages/shared/src/logic/index.ts | 1 - .../shared/src/logic/performanceLogging.ts | 48 ------------------- 7 files changed, 6 insertions(+), 61 deletions(-) delete mode 100644 packages/shared/src/logic/performanceLogging.ts diff --git a/apps/tlon-mobile/android/app/build.gradle b/apps/tlon-mobile/android/app/build.gradle index 8572d5c316..ccb2ed95b4 100644 --- a/apps/tlon-mobile/android/app/build.gradle +++ b/apps/tlon-mobile/android/app/build.gradle @@ -3,6 +3,7 @@ apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply plugin: "com.google.gms.google-services" apply plugin: "com.google.firebase.crashlytics" +apply plugin: 'com.google.firebase.firebase-perf' def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() diff --git a/apps/tlon-mobile/android/build.gradle b/apps/tlon-mobile/android/build.gradle index 743999c962..0ff6c5b9d1 100644 --- a/apps/tlon-mobile/android/build.gradle +++ b/apps/tlon-mobile/android/build.gradle @@ -19,7 +19,8 @@ buildscript { classpath('com.android.tools.build:gradle') classpath('com.facebook.react:react-native-gradle-plugin') classpath('com.google.firebase:firebase-crashlytics-gradle:2.9.9') - } + classpath('com.google.firebase:perf-plugin:1.4.2') + } } apply plugin: "com.facebook.react.rootproject" diff --git a/apps/tlon-mobile/src/App.main.tsx b/apps/tlon-mobile/src/App.main.tsx index b000cd0359..23dcef4e0a 100644 --- a/apps/tlon-mobile/src/App.main.tsx +++ b/apps/tlon-mobile/src/App.main.tsx @@ -2,6 +2,7 @@ import { useAsyncStorageDevTools } from '@dev-plugins/async-storage'; import { useReactNavigationDevTools } from '@dev-plugins/react-navigation'; import { useReactQueryDevTools } from '@dev-plugins/react-query'; import NetInfo from '@react-native-community/netinfo'; +import perf from '@react-native-firebase/perf'; import { DarkTheme, DefaultTheme, @@ -65,6 +66,8 @@ const App = ({ const { lure, priorityToken } = useBranch(); const screenOptions = useScreenOptions(); + perf().startScreenTrace('App'); + useEffect(() => { const unsubscribeFromNetInfo = NetInfo.addEventListener( ({ isConnected }) => { diff --git a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx index e167546389..5b5170e4d2 100644 --- a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx +++ b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx @@ -1,6 +1,5 @@ import crashlytics from '@react-native-firebase/crashlytics'; import { setCrashReporter, sync } from '@tloncorp/shared'; -import { FirebasePerformanceMonitor } from '@tloncorp/shared'; import { QueryClientProvider, queryClient } from '@tloncorp/shared/dist/api'; import * as logic from '@tloncorp/shared/dist/logic'; import { ZStack } from '@tloncorp/ui'; @@ -36,7 +35,6 @@ function AuthenticatedApp({ }); setCrashReporter(crashlytics()); - logic.setPerformanceMonitor(new FirebasePerformanceMonitor()); // TODO: remove, for use in Beta testing only if (currentUserId) { diff --git a/apps/tlon-web/test/setup.ts b/apps/tlon-web/test/setup.ts index 27c04299eb..ad235fd5bd 100644 --- a/apps/tlon-web/test/setup.ts +++ b/apps/tlon-web/test/setup.ts @@ -24,15 +24,6 @@ vi.mock('posthog-js', () => ({ }, })); -vi.mock('@react-native-firebase/perf', () => ({ - default: () => ({ - newTrace: (traceName: string) => ({ - start: vi.fn(), - stop: vi.fn(), - }), - }), -})); - // Prevent vite from failing when resizeObserver is used Object.defineProperty(global, 'ResizeObserver', { diff --git a/packages/shared/src/logic/index.ts b/packages/shared/src/logic/index.ts index 367083a03b..85f9bb14a4 100644 --- a/packages/shared/src/logic/index.ts +++ b/packages/shared/src/logic/index.ts @@ -4,4 +4,3 @@ export * from './embed'; export * from './types'; export * from './activity'; export * from './errorReporting'; -export * from './performanceLogging'; diff --git a/packages/shared/src/logic/performanceLogging.ts b/packages/shared/src/logic/performanceLogging.ts deleted file mode 100644 index e3a76d5261..0000000000 --- a/packages/shared/src/logic/performanceLogging.ts +++ /dev/null @@ -1,48 +0,0 @@ -let perfModule: any = null; - -export type PerformanceMonitor = { - startTrace: (traceName: string) => Promise; - stopTrace: (traceName: string) => Promise; -}; - -export class FirebasePerformanceMonitor implements PerformanceMonitor { - private traces: { [key: string]: any } = {}; - - constructor() { - if (typeof window === 'undefined') { - perfModule = require('@react-native-firebase/perf').default; - } - } - - async startTrace(traceName: string) { - if (!this.traces[traceName] && perfModule) { - this.traces[traceName] = perfModule().newTrace(traceName); - await this.traces[traceName].start(); - } - } - - async stopTrace(traceName: string) { - if (this.traces[traceName]) { - await this.traces[traceName].stop(); - delete this.traces[traceName]; - } - } -} - -let performanceMonitorInstance: PerformanceMonitor | null = null; - -export function setPerformanceMonitor(client: T) { - performanceMonitorInstance = client; -} - -export const PerformanceMonitor = new Proxy( - {}, - { - get: function (target, prop, receiver) { - if (!performanceMonitorInstance) { - throw new Error('Performance monitor not set!'); - } - return Reflect.get(performanceMonitorInstance, prop, receiver); - }, - } -) as PerformanceMonitor; From 9c7619eb71a84abf68287561be8d2f1a862874d2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 27 Jun 2024 18:12:32 +0000 Subject: [PATCH 07/19] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index dc8a2f7ab3..0129de3f90 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-0v7.f6rgh.7hdem.h7bn0.dibka.phl0k.glob' 0v7.f6rgh.7hdem.h7bn0.dibka.phl0k] + glob-http+['https://bootstrap.urbit.org/glob-0v2.blsuf.uovnn.jlaef.84mu6.9m8lo.glob' 0v2.blsuf.uovnn.jlaef.84mu6.9m8lo] base+'groups' version+[6 0 2] website+'https://tlon.io' From 9f8cf1186a9066bffb30d3a9d2de91bf0d0e7819 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Thu, 27 Jun 2024 14:02:28 -0500 Subject: [PATCH 08/19] Use canonical ochre colors for heroDestructive button variant --- packages/ui/src/components/Button.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 0e571e82c9..f507004cfa 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -89,14 +89,14 @@ export const ButtonFrame = styled(Stack, { } as const, heroDestructive: { true: { - backgroundColor: '$red', + backgroundColor: '$background', padding: '$xl', - borderWidth: 0, + borderWidth: 1, pressStyle: { - backgroundColor: '$redSoft', + backgroundColor: '$negativeBackground', }, disabledStyle: { - backgroundColor: '$gray600', + backgroundColor: '$secondaryText', }, }, } as const, @@ -150,7 +150,7 @@ export const ButtonText = styled(Text, { }, heroDestructive: { true: { - color: '$white', + color: '$negativeActionText', width: '100%', textAlign: 'center', fontWeight: '500', From f854fdf4b8eef0c3e5601f014df20afa9c4b7105 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 27 Jun 2024 15:08:21 -0500 Subject: [PATCH 09/19] channels: dont send activity for edits --- desk/app/channels.hoon | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/desk/app/channels.hoon b/desk/app/channels.hoon index 689965ed31..8915a5c68d 100644 --- a/desk/app/channels.hoon +++ b/desk/app/channels.hoon @@ -1210,10 +1210,9 @@ =? recency.remark.channel ?=(^ post.u-post) (max recency.remark.channel id-post) =? ca-core ?& ?=(^ post.u-post) - |(?=(~ post) (gth rev.u.post.u-post rev.u.u.post)) + ?=(~ post) == - ::REVIEW this might re-submit on edits. is that what we want? - :: it looks like %activity inserts even if it's a duplicate. + :: we don't send an activity event for edits or deletes (on-post:ca-activity u.post.u-post) ?~ post =/ post=(unit post:c) (bind post.u-post uv-post:utils) From 0a527d00a1b599d7e336432cae1a3e97866fead2 Mon Sep 17 00:00:00 2001 From: fang Date: Thu, 27 Jun 2024 22:15:43 +0200 Subject: [PATCH 10/19] various: add resource-id-ing ~| into common paths We see crash traces for all kinds of things. Currently, we print poke and watch nack traces to the user's terminal. These by themselves only point at the codepath, but don't explicate the crash reason (which could also be deduced from the trace) or the resource that was operated on (which might not be recoverable in cases where we don't have/print the wire). Here, we add a couple of trace hints, to print subscription paths and resource identifiers in common crash pathways. --- desk/app/channels-server.hoon | 16 +++++++++++----- desk/app/groups.hoon | 4 +++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/desk/app/channels-server.hoon b/desk/app/channels-server.hoon index 0daf0ea53a..322ace0b46 100644 --- a/desk/app/channels-server.hoon +++ b/desk/app/channels-server.hoon @@ -279,7 +279,8 @@ ++ watch |= =(pole knot) ^+ cor - ?+ pole ~|(bad-watch-path+pole !!) + ~| watch-path=`path`pole + ?+ pole ~|(%bad-watch-path !!) [=kind:c name=@ %create ~] ?> =(our src):bowl =* nest [kind.pole our.bowl name.pole] @@ -288,7 +289,8 @@ :: [=kind:c name=@ %updates ~] =/ ca (ca-abed:ca-core kind.pole our.bowl name.pole) - ?> (can-read:ca-perms:ca src.bowl) + ?. (can-read:ca-perms:ca src.bowl) + ~|(%permission-denied !!) cor :: [=kind:c name=@ %updates after=@ ~] @@ -425,6 +427,7 @@ :: ++ ca-abed |= n=nest:c + ~| nest=n ca-core(nest n, channel (~(got by v-channels) n)) :: ++ ca-area `path`/[kind.nest]/[name.nest] @@ -436,7 +439,8 @@ ++ ca-watch-updates |= =@da ^+ ca-core - ?> (can-read:ca-perms src.bowl) + ?. (can-read:ca-perms src.bowl) + ~|(%permission-denied !!) =/ =log:c (lot:log-on:c log.channel `da ~) =. ca-core (give %fact ~ %channel-logs !>(log)) ca-core @@ -444,7 +448,8 @@ ++ ca-watch-checkpoint |= [from=@da to=(unit @da)] ^+ ca-core - ?> (can-read:ca-perms src.bowl) + ?. (can-read:ca-perms src.bowl) + ~|(%permission-denied !!) =/ posts=v-posts:c (lot:on-v-posts:c posts.channel `from to) =/ chk=u-checkpoint:c -.channel(posts posts) =. ca-core (give %fact ~ %channel-checkpoint !>(chk)) @@ -453,7 +458,8 @@ ++ ca-watch-checkpoint-page |= n=@ ^+ ca-core - ?> (can-read:ca-perms src.bowl) + ?. (can-read:ca-perms src.bowl) + ~|(%permission-denied !!) =/ posts=v-posts:c (gas:on-v-posts:c *v-posts:c (bat:mo-v-posts:c posts.channel ~ n)) =/ chk=u-checkpoint:c -.channel(posts posts) =. ca-core (give %fact ~ %channel-checkpoint !>(chk)) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index 07d06fa077..207f741cfd 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -379,7 +379,8 @@ ++ watch |= =(pole knot) ^+ cor - ?+ pole ~|(bad-watch/pole !!) + ~| watch-path=`path`pole + ?+ pole ~|(%bad-watch-path !!) [%init ~] (give %kick ~ ~) [%groups ~] cor [%groups %ui ~] cor @@ -799,6 +800,7 @@ ++ go-abed |= f=flag:g ^+ go-core + ~| flag=f =/ [n=net:g gr=group:g] (~(got by groups) f) go-core(flag f, group gr, net n) :: From ee1571b0f2d88c2c0db108a917a70630082305de Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 27 Jun 2024 16:40:32 -0500 Subject: [PATCH 11/19] chat: simplify messages calculations and add deleted messages --- .../src/chat/ChatMessage/ChatMessage.tsx | 16 +-- .../chat/ChatMessage/ChatMessageOptions.tsx | 5 +- .../chat/ChatMessage/DeletedChatMessage.tsx | 121 +++++++++++++++++ .../src/chat/ChatScroller/ChatScroller.tsx | 5 + apps/tlon-web/src/dms/MessagesList.tsx | 31 +++-- apps/tlon-web/src/dms/MessagesSidebarItem.tsx | 7 +- .../tlon-web/src/logic/useScrollerMessages.ts | 126 ++++++------------ 7 files changed, 194 insertions(+), 117 deletions(-) create mode 100644 apps/tlon-web/src/chat/ChatMessage/DeletedChatMessage.tsx diff --git a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx index 98dc7347d0..0ee188e686 100644 --- a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx +++ b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx @@ -68,8 +68,6 @@ import { useChatDialogs, useChatHovering, useChatInfo, - useChatKeys, - useChatStore, } from '../useChatStore'; export interface ChatMessageProps { @@ -334,18 +332,8 @@ const ChatMessage = React.memo< // If we're the thread op, don't show options. // Options are shown for the threadOp in the main scroll window. - setOptionsOpen( - (hovering || pickerOpen) && - !isScrolling && - !isThreadOp - ); - }, [ - isMobile, - hovering, - pickerOpen, - isScrolling, - isThreadOp, - ]); + setOptionsOpen((hovering || pickerOpen) && !isScrolling && !isThreadOp); + }, [isMobile, hovering, pickerOpen, isScrolling, isThreadOp]); const onSubmit = useCallback( async (editor: Editor) => { diff --git a/apps/tlon-web/src/chat/ChatMessage/ChatMessageOptions.tsx b/apps/tlon-web/src/chat/ChatMessage/ChatMessageOptions.tsx index 12f51a83c3..0fed574eae 100644 --- a/apps/tlon-web/src/chat/ChatMessage/ChatMessageOptions.tsx +++ b/apps/tlon-web/src/chat/ChatMessage/ChatMessageOptions.tsx @@ -33,6 +33,7 @@ import { inlineSummary } from '@/logic/tiptap'; import useGroupPrivacy from '@/logic/useGroupPrivacy'; import { useIsMobile } from '@/logic/useMedia'; import { useCopy, useIsDmOrMultiDm } from '@/logic/utils'; +import { deleteBroadcast } from '@/state/broadcasts'; import { useAddPostReactMutation, useDeletePostMutation, @@ -52,7 +53,6 @@ import { useRouteGroup, useVessel, } from '@/state/groups'; -import { deleteBroadcast } from '@/state/broadcasts'; function ChatMessageOptions(props: { open: boolean; @@ -138,8 +138,7 @@ function ChatMessageOptions(props: { await new Promise((resolve) => { deleteBroadcast(whom, seal.id, resolve, resolve); }); - } else - if (isDMorMultiDM) { + } else if (isDMorMultiDM) { await deleteDm({ whom, id: seal.id }); } else { await deleteChatMessage({ diff --git a/apps/tlon-web/src/chat/ChatMessage/DeletedChatMessage.tsx b/apps/tlon-web/src/chat/ChatMessage/DeletedChatMessage.tsx new file mode 100644 index 0000000000..56135bfb01 --- /dev/null +++ b/apps/tlon-web/src/chat/ChatMessage/DeletedChatMessage.tsx @@ -0,0 +1,121 @@ +/* eslint-disable react/no-unused-prop-types */ +import { getKey } from '@tloncorp/shared/dist/urbit/activity'; +import { daToUnix } from '@urbit/api'; +import { formatUd } from '@urbit/aura'; +import { BigInteger } from 'big-integer'; +import cn from 'classnames'; +import { format } from 'date-fns'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { useInView } from 'react-intersection-observer'; + +import DateDivider from '@/chat/ChatMessage/DateDivider'; +import { useMarkChannelRead } from '@/logic/channel'; +import { useUnread, useUnreadsStore } from '@/state/unreads'; + +export interface DeletedChatMessageProps { + whom: string; + time: BigInteger; + newDay?: boolean; + isBroadcast?: boolean; + isLast?: boolean; + isLinked?: boolean; + isScrolling?: boolean; +} + +const mergeRefs = + (...refs: any[]) => + (node: any) => { + refs.forEach((ref) => { + if (!ref) { + return; + } + + /* eslint-disable-next-line no-param-reassign */ + ref.current = node; + }); + }; + +const DeletedChatMessage = React.memo< + DeletedChatMessageProps & React.RefAttributes +>( + React.forwardRef( + ( + { + whom, + time, + newDay = false, + isLast = false, + isLinked = false, + }: DeletedChatMessageProps, + ref + ) => { + const container = useRef(null); + const unix = new Date(daToUnix(time)); + const unreadsKey = getKey(whom); + const unread = useUnread(unreadsKey); + const isUnread = useMemo( + () => unread && unread.lastUnread?.time === time.toString(), + [unread, time] + ); + const { markRead: markReadChannel } = useMarkChannelRead(`chat/${whom}`); + + const { ref: viewRef, inView } = useInView({ + threshold: 1, + }); + + useEffect(() => { + if (!inView || !unread) { + return; + } + + const unseen = unread.status === 'unread'; + const { seen: markSeen, delayedRead } = useUnreadsStore.getState(); + /* once the unseen marker comes into view we need to mark it + as seen and start a timer to mark it read so it goes away. + we ensure that the brief matches and hasn't changed before + doing so. we don't want to accidentally clear unreads when + the state has changed + */ + if (inView && isUnread && unseen) { + markSeen(unreadsKey); + delayedRead(unreadsKey, markReadChannel); + } + }, [inView, unread, unreadsKey, isUnread, markReadChannel]); + + return ( +
+ {unread && isUnread ? ( + + ) : null} + {newDay && !isUnread ? : null} +
+
+ {format(unix, 'HH:mm')} +
+
+ This message was deleted +
+
+
+ ); + } + ) +); + +export default DeletedChatMessage; diff --git a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx index fe8230ed7e..87557110cb 100644 --- a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx +++ b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx @@ -37,6 +37,7 @@ import { createDevLogger, useObjectChangeLogging } from '@/logic/utils'; import ReplyMessage from '@/replies/ReplyMessage'; import { useShowDevTools } from '@/state/local'; +import DeletedChatMessage from '../ChatMessage/DeletedChatMessage'; import ChatScrollerDebugOverlay from './ChatScrollerDebugOverlay'; const logger = createDevLogger('ChatScroller', false); @@ -63,6 +64,10 @@ const ChatScrollerItem = React.memo( const { writ, time, ...rest } = item; + if (!writ) { + return ; + } + if ('memo' in writ) { if (!rest.parent) { return; diff --git a/apps/tlon-web/src/dms/MessagesList.tsx b/apps/tlon-web/src/dms/MessagesList.tsx index 6412b36970..d38e5322fb 100644 --- a/apps/tlon-web/src/dms/MessagesList.tsx +++ b/apps/tlon-web/src/dms/MessagesList.tsx @@ -71,16 +71,15 @@ export default function MessagesList({ const organizedUnreads = useMemo(() => { const filteredMsgs = sortMessages( - filter === filters.broadcasts - ? Object.fromEntries( - Object.entries(broadcasts.data || {}).map( - (v): [string, Unread] => { - return [v[0], cohortToUnread(v[1] as Cohort)]; //REVIEW hax - } - ) - ) - : unreads - ).filter(([k]) => { + filter === filters.broadcasts + ? Object.fromEntries( + Object.entries(broadcasts.data || {}).map((v): [string, Unread] => { + return [v[0], cohortToUnread(v[1] as Cohort)]; //REVIEW hax + }) + ) + : unreads + ) + .filter(([k]) => { if (k.startsWith('~~') && filter === filters.broadcasts) { return true; } @@ -143,7 +142,17 @@ export default function MessagesList({ .filter(searchQuery, filteredMsgs, { extract: (x) => x[0] }) .sort((a, b) => b.score - a.score) .map((x) => x.original); - }, [sortMessages, unreads, chats, groups, pinned, searchQuery, allPending, filter, broadcasts]); + }, [ + sortMessages, + unreads, + chats, + groups, + pinned, + searchQuery, + allPending, + filter, + broadcasts, + ]); const headerHeightRef = useRef(0); const headerRef = useRef(null); diff --git a/apps/tlon-web/src/dms/MessagesSidebarItem.tsx b/apps/tlon-web/src/dms/MessagesSidebarItem.tsx index bc6a267cd1..122ae70b7d 100644 --- a/apps/tlon-web/src/dms/MessagesSidebarItem.tsx +++ b/apps/tlon-web/src/dms/MessagesSidebarItem.tsx @@ -9,7 +9,12 @@ import ShipName from '../components/ShipName'; import SidebarItem from '../components/Sidebar/SidebarItem'; import GroupAvatar from '../groups/GroupAvatar'; import useMedia, { useIsMobile } from '../logic/useMedia'; -import { nestToFlag, whomIsBroadcast, whomIsDm, whomIsMultiDm } from '../logic/utils'; +import { + nestToFlag, + whomIsBroadcast, + whomIsDm, + whomIsMultiDm, +} from '../logic/utils'; import { useMultiDm } from '../state/chat'; import { useGroup, useGroupChannel, useGroups } from '../state/groups/groups'; import BroadcastOptions from './BroadcastOptions'; diff --git a/apps/tlon-web/src/logic/useScrollerMessages.ts b/apps/tlon-web/src/logic/useScrollerMessages.ts index 3cc809f0b7..d35f84ec01 100644 --- a/apps/tlon-web/src/logic/useScrollerMessages.ts +++ b/apps/tlon-web/src/logic/useScrollerMessages.ts @@ -9,79 +9,55 @@ import getKindDataFromEssay from './getKindData'; export type WritArray = [BigInteger, Writ | Post | Reply | null][]; -const getMessageAuthor = (writ: Writ | Post | Reply) => { +const getMessageAuthor = (writ: Writ | Post | Reply | null | undefined) => { + if (!writ) { + return null; + } + if ('memo' in writ) { return writ.memo.author; } return writ.essay.author; }; -function getDay( - id: string, - time: bigInt.BigInteger, - messageDays: Map -) { - let day = messageDays.get(id); +function getDay(time: bigInt.BigInteger, messageDays: Map) { + let day = messageDays.get(time.toString()); if (!day) { day = new Date(daToUnix(time)).setHours(0, 0, 0, 0); - messageDays.set(id, day); + messageDays.set(time.toString(), day); } return day; } const getNewDayAndNewAuthorFromLastWrit = ( - messages: WritArray, - writ: Writ | Post | Reply, + author: string | null, key: bigInt.BigInteger, - messageDays: Map, - index: number + lastWrit: [BigInteger, Writ | Post | Reply | null] | undefined, + messageDays: Map ) => { - const lastWrit = index === 0 ? undefined : messages[index - 1]; - const newAuthor = - lastWrit && lastWrit[1] - ? getMessageAuthor(writ) !== getMessageAuthor(lastWrit[1]) || - ('essay' in lastWrit[1] && - !!getKindDataFromEssay(lastWrit[1].essay).notice) - : true; - const newDay = - !lastWrit || - !( - lastWrit[1] && - getDay(lastWrit[1]?.seal.id, lastWrit[0], messageDays) === - getDay(writ.seal.id, key, messageDays) - ); + const prevKey = lastWrit?.[0]; + const prevWrit = lastWrit?.[1]; + const prevAuthor = getMessageAuthor(prevWrit); + const prevIsNotice = + prevWrit && + 'essay' in prevWrit && + !!getKindDataFromEssay(prevWrit.essay).notice; + const newAuthor = prevIsNotice + ? false + : !author || !prevAuthor + ? true + : author !== prevAuthor; + const newDay = !prevKey + ? true + : getDay(prevKey, messageDays) !== getDay(key, messageDays); return { - writ, - time: key, newAuthor, newDay, }; }; -const emptyWrit = { - seal: { - id: '', - time: '', - replies: [], - reacts: {}, - meta: { - replyCount: 0, - lastRepliers: [], - lastReply: null, - }, - }, - essay: { - content: [], - author: window.our, - sent: Date.now(), - 'kind-data': { - chat: null, - }, - }, -}; - export type MessageListItemData = { - writ: Writ | Post | Reply; + writ: Writ | Post | Reply | null; type: 'message'; time: bigInt.BigInteger; newAuthor: boolean; @@ -106,57 +82,31 @@ function useMessageItems({ time: bigInt.BigInteger; newAuthor: boolean; newDay: boolean; - writ: Writ | Post | Reply; + writ: Writ | Post | Reply | null; }[], WritArray, ] { const messageDays = useRef(new Map()); const [keys, entries] = useMemo(() => { - const nonNullMessages = messages.filter(([_k, v]) => v !== null); - const ks: bigInt.BigInteger[] = nonNullMessages.map(([k]) => k); - const es = nonNullMessages.map(([key, writ], index) => { - if (!writ) { - return { - writ: emptyWrit, - hideReplies: true, - time: key, - newAuthor: false, - newDay: false, - isLast: false, - isLinked: false, - }; - } - + const ks: bigInt.BigInteger[] = messages.map(([k]) => k); + const es = messages.map(([key, writ], index) => { + const lastWrit = index === 0 ? undefined : messages[index - 1]; const { newAuthor, newDay } = getNewDayAndNewAuthorFromLastWrit( - nonNullMessages, - writ, + getMessageAuthor(writ), key, - messageDays.current, - index + lastWrit, + messageDays.current ); - if ('memo' in writ) { - return { - writ, - time: key, - newAuthor, - newDay, - parent, - }; - } - - const isNotice = - 'chat' in writ.essay['kind-data'] && - writ.essay['kind-data'].chat && - 'notice' in writ.essay['kind-data'].chat; - return { writ, time: key, - newAuthor: isNotice ? false : newAuthor, + newAuthor, newDay, - replyCount: writ.seal.meta.replyCount, + parent: writ && 'memo' in writ ? parent : undefined, + replyCount: + writ && 'essay' in writ ? writ.seal.meta.replyCount : undefined, }; }, []); return [ks, es]; From 223fed8129b7a82f4873df0336c0e1a7759adc69 Mon Sep 17 00:00:00 2001 From: ~latter-bolden Date: Thu, 27 Jun 2024 18:46:43 -0400 Subject: [PATCH 12/19] minor changes to counts --- packages/shared/src/api/activityApi.ts | 6 +----- packages/shared/src/api/unreads.test.ts | 2 +- packages/shared/src/db/queries.ts | 2 +- .../ui/src/components/Activity/ChannelActivitySummary.tsx | 2 +- packages/ui/src/components/ChannelListItem/index.tsx | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/api/activityApi.ts b/packages/shared/src/api/activityApi.ts index a9e19080a3..815e0de7bb 100644 --- a/packages/shared/src/api/activityApi.ts +++ b/packages/shared/src/api/activityApi.ts @@ -675,13 +675,9 @@ export const toGroupUnread = ( groupId: string, summary: ub.ActivitySummary ): db.GroupUnread => { - const count = Object.values(summary.children ?? {}).reduce((acc, entry) => { - const childCount = entry.unread?.count ?? 0; - return acc + childCount; - }, 0); return { groupId, - count, + count: summary.count, notify: summary.notify, updatedAt: summary.recency, }; diff --git a/packages/shared/src/api/unreads.test.ts b/packages/shared/src/api/unreads.test.ts index 25e7f1bd2f..6b469c26a4 100644 --- a/packages/shared/src/api/unreads.test.ts +++ b/packages/shared/src/api/unreads.test.ts @@ -236,7 +236,7 @@ const groupUnread: Record = { const expectedGroupUnread = { groupId: '~latter-bolden/woodshop', updatedAt: 946684800000, - count: 5, + count: 6, notify: true, }; diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index b4ca5ab63a..1af6f72e58 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -296,7 +296,7 @@ export const getChats = createReadQuery( .orderBy( ascNullsLast($pins.index), sql`(CASE WHEN ${$groups.isNew} = 1 THEN 1 ELSE 0 END) DESC`, - sql`COALESCE(${allChannels.lastPostAt}, ${$channelUnreads.updatedAt}) DESC` + sql`COALESCE(${$channelUnreads.updatedAt}, ${allChannels.lastPostAt}) DESC` ); const [chatMembers, filteredChannels] = result.reduce< diff --git a/packages/ui/src/components/Activity/ChannelActivitySummary.tsx b/packages/ui/src/components/Activity/ChannelActivitySummary.tsx index 87b9908a51..6f0d1e978b 100644 --- a/packages/ui/src/components/Activity/ChannelActivitySummary.tsx +++ b/packages/ui/src/components/Activity/ChannelActivitySummary.tsx @@ -25,7 +25,7 @@ export function ChannelActivitySummary({ const unreadCount = summary.type === 'post' ? newestPost.channel?.unread?.countWithoutThreads ?? 0 - : newestPost.post?.threadUnread?.count ?? 0; + : newestPost.parent?.threadUnread?.count ?? 0; const newestIsBlockOrNote = (summary.type === 'post' && newestPost.channel?.type === 'gallery') || diff --git a/packages/ui/src/components/ChannelListItem/index.tsx b/packages/ui/src/components/ChannelListItem/index.tsx index a330c97f01..f88a7da684 100644 --- a/packages/ui/src/components/ChannelListItem/index.tsx +++ b/packages/ui/src/components/ChannelListItem/index.tsx @@ -20,7 +20,7 @@ export default function ChannelListItem({ }: { useTypeIcon?: boolean; } & ListItemProps) { - const countToShow = model.unread?.countWithoutThreads ?? 0; + const countToShow = model.unread?.count ?? 0; const title = utils.getChannelTitle(model); return ( From b6941144e271f12743037b2da635a95d7c0ada1e Mon Sep 17 00:00:00 2001 From: fang Date: Fri, 28 Jun 2024 15:10:55 +0200 Subject: [PATCH 13/19] activity: increment time-ids in "better" steps Instead of incrementing the atom, add a proper fraction to it. This way, we don't end up with crazy long `@da` renderings all over the place. --- desk/app/activity.hoon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/app/activity.hoon b/desk/app/activity.hoon index 7d9f6e367b..ef2f08fd1c 100644 --- a/desk/app/activity.hoon +++ b/desk/app/activity.hoon @@ -587,7 +587,7 @@ =/ t start-time |- ?. (has:on-event:a stream:base t) t - $(t +(t)) + $(t (^add t ~s0..0001)) =/ notify notify:(get-volume inc) =/ =event:a [inc notify |] =/ =source:a (determine-source inc) From 71522636e274823efe5e38843b1207e58b49c58b Mon Sep 17 00:00:00 2001 From: Dan Brewster Date: Fri, 28 Jun 2024 11:08:28 -0400 Subject: [PATCH 14/19] post loading ux (#3674) * debounce post load state * native: improve post load logic --- .../tlon-mobile/src/screens/ChannelScreen.tsx | 15 +++++++++++ packages/shared/src/logic/utilHooks.ts | 15 ++++++++++- packages/shared/src/store/index.ts | 1 + packages/shared/src/store/session.ts | 26 ++++++++++++++++++ packages/shared/src/store/sync.ts | 11 ++++++++ packages/shared/src/store/useChannelPosts.ts | 27 ++++++++++++++----- .../ui/src/components/Channel/Scroller.tsx | 4 +-- 7 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/store/session.ts diff --git a/apps/tlon-mobile/src/screens/ChannelScreen.tsx b/apps/tlon-mobile/src/screens/ChannelScreen.tsx index c62e473a42..fb02f4d0e0 100644 --- a/apps/tlon-mobile/src/screens/ChannelScreen.tsx +++ b/apps/tlon-mobile/src/screens/ChannelScreen.tsx @@ -57,6 +57,20 @@ export default function ChannelScreen(props: ChannelScreenProps) { uploaderKey: `${currentChannelId}`, }); + const session = store.useCurrentSession(); + const hasCachedNewest = useMemo(() => { + if (!session || !channel) { + return false; + } + const { syncedAt, lastPostAt } = channel; + if (syncedAt && session.startTime < syncedAt) { + return true; + } else if (lastPostAt && syncedAt && syncedAt > lastPostAt) { + return true; + } + return false; + }, [channel, session]); + const selectedPostId = props.route.params.selectedPostId; const cursor = useMemo(() => { if (!channel) { @@ -81,6 +95,7 @@ export default function ChannelScreen(props: ChannelScreenProps) { enabled: !!channel, channelId: currentChannelId, count: 50, + hasCachedNewest, ...(cursor ? { mode: 'around', diff --git a/packages/shared/src/logic/utilHooks.ts b/packages/shared/src/logic/utilHooks.ts index c66de4d1fe..ce44ffe4e3 100644 --- a/packages/shared/src/logic/utilHooks.ts +++ b/packages/shared/src/logic/utilHooks.ts @@ -1,5 +1,5 @@ import { replaceEqualDeep } from '@tanstack/react-query'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; /** * Creates a ref whose current value is always the value as of last render. @@ -34,3 +34,16 @@ export function useOptimizedQueryResults( ); }, [value]); } + +export function useDebouncedValue(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => clearTimeout(timeout); + }, [value, delay]); + + return debouncedValue; +} diff --git a/packages/shared/src/store/index.ts b/packages/shared/src/store/index.ts index cfbeebba81..7902a3ee15 100644 --- a/packages/shared/src/store/index.ts +++ b/packages/shared/src/store/index.ts @@ -12,3 +12,4 @@ export * from './dmActions'; export * from './useNegotiation'; export * from './activityActions'; export * from './useActivityFetchers'; +export * from './session'; diff --git a/packages/shared/src/store/session.ts b/packages/shared/src/store/session.ts new file mode 100644 index 0000000000..7e5949e6c3 --- /dev/null +++ b/packages/shared/src/store/session.ts @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from 'react'; + +type Session = { startTime: number }; +type SessionListener = (session: Session) => void; + +let session: Session | null = null; +const sessionListeners: SessionListener[] = []; + +export function getSession() { + return session; +} + +export function updateSession(newSession: Session | null) { + session = newSession; +} + +function subscribeToSession(listener: SessionListener) { + sessionListeners.push(listener); + return () => { + sessionListeners.splice(sessionListeners.indexOf(listener), 1); + }; +} + +export function useCurrentSession() { + return useSyncExternalStore(subscribeToSession, getSession); +} diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts index cfad92a038..93f3ed7a70 100644 --- a/packages/shared/src/store/sync.ts +++ b/packages/shared/src/store/sync.ts @@ -11,6 +11,7 @@ import { INFINITE_ACTIVITY_QUERY_KEY, resetActivityFetchers, } from '../store/useActivityFetchers'; +import { updateSession } from './session'; import { useStorage } from './storage'; import { syncQueue } from './syncQueue'; import { addToChannelPosts, clearChannelPostsQueries } from './useChannelPosts'; @@ -627,6 +628,12 @@ export async function syncPosts(options: api.GetChannelPostsOptions) { older: response.older, }); } + if (!response.newer) { + await db.updateChannel({ + id: options.channelId, + syncedAt: Date.now(), + }); + } return response; } @@ -840,6 +847,8 @@ export const initializeStorage = () => { concerns and punts on full correctness. */ export const handleDiscontinuity = async () => { + updateSession(null); + // drop potentially outdated newest post markers channelCursors.clear(); @@ -874,6 +883,7 @@ export const syncStart = async (alreadySubscribed?: boolean) => { } else { reporter.log(`already subscribed, skipping`); } + updateSession({ startTime: Date.now() }); await withRetry(() => Promise.all([ @@ -889,6 +899,7 @@ export const syncStart = async (alreadySubscribed?: boolean) => { ), ]) ); + reporter.log('sync start complete'); } catch (e) { reporter.report(e); diff --git a/packages/shared/src/store/useChannelPosts.ts b/packages/shared/src/store/useChannelPosts.ts index 41bcb61105..6f4339e6b9 100644 --- a/packages/shared/src/store/useChannelPosts.ts +++ b/packages/shared/src/store/useChannelPosts.ts @@ -8,7 +8,11 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as db from '../db'; import { createDevLogger } from '../debug'; -import { useLiveRef, useOptimizedQueryResults } from '../logic/utilHooks'; +import { + useDebouncedValue, + useLiveRef, + useOptimizedQueryResults, +} from '../logic/utilHooks'; import { queryClient } from './reactQuery'; import * as sync from './sync'; @@ -22,6 +26,7 @@ type SubscriptionPost = [db.Post, string | undefined]; type UseChanelPostsParams = UseChannelPostsPageParams & { enabled: boolean; firstPageCount?: number; + hasCachedNewest?: boolean; }; export const clearChannelPostsQueries = () => { @@ -50,13 +55,19 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { queryFn: async (ctx): Promise => { const queryOptions = ctx.pageParam || options; postsLogger.log('loading posts', queryOptions); + if (queryOptions.mode === 'newest' && !options.hasCachedNewest) { + await sync.syncPosts(queryOptions); + } const cached = await db.getChannelPosts(queryOptions); if (cached?.length) { postsLogger.log('returning', cached.length, 'posts from db'); return cached; } postsLogger.log('no posts found in database, loading from api...'); - const res = await sync.syncPosts(queryOptions); + const res = await sync.syncPosts({ + ...queryOptions, + count: options.count ?? 50, + }); postsLogger.log('loaded', res.posts?.length, 'posts from api'); const secondResult = await db.getChannelPosts(queryOptions); postsLogger.log( @@ -99,7 +110,7 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { _firstPageParam ): UseChannelPostsPageParams | undefined => { const firstPageIsEmpty = !firstPage[0]?.id; - if (firstPageIsEmpty) { + if (firstPageIsEmpty || options.hasCachedNewest) { return undefined; } return { @@ -124,11 +135,13 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { useAddNewPostsToQuery(queryKey, query); - const isLoading = + const isLoading = useDebouncedValue( query.isPending || - query.isPaused || - query.isFetchingNextPage || - query.isFetchingPreviousPage; + query.isPaused || + query.isFetchingNextPage || + query.isFetchingPreviousPage, + 100 + ); const { loadOlder, loadNewer } = useLoadActionsWithPendingHandlers(query); diff --git a/packages/ui/src/components/Channel/Scroller.tsx b/packages/ui/src/components/Channel/Scroller.tsx index 72caa8d59d..c78915625f 100644 --- a/packages/ui/src/components/Channel/Scroller.tsx +++ b/packages/ui/src/components/Channel/Scroller.tsx @@ -465,9 +465,9 @@ function Scroller({ numColumns={channelType === 'gallery' ? 2 : 1} style={style} onEndReached={handleEndReached} - onEndReachedThreshold={2} + onEndReachedThreshold={0.25} onStartReached={handleStartReached} - onStartReachedThreshold={2} + onStartReachedThreshold={0.25} onScroll={handleScroll} /> )} From 5fc64cf25001a5ed9b40fe989c6bb2806bb73288 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 28 Jun 2024 15:13:01 +0000 Subject: [PATCH 15/19] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 0129de3f90..1ce3523000 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-0v2.blsuf.uovnn.jlaef.84mu6.9m8lo.glob' 0v2.blsuf.uovnn.jlaef.84mu6.9m8lo] + glob-http+['https://bootstrap.urbit.org/glob-0v7.jehvc.r34gn.7rsbc.7giu4.6ghb0.glob' 0v7.jehvc.r34gn.7rsbc.7giu4.6ghb0] base+'groups' version+[6 0 2] website+'https://tlon.io' From 7da499e19496f3858511a564e73bbb7349e3e7af Mon Sep 17 00:00:00 2001 From: James Acklin <748181+jamesacklin@users.noreply.github.com> Date: Fri, 28 Jun 2024 11:39:36 -0400 Subject: [PATCH 16/19] native: remove custom trace iOS is barking about not supporting custom traces quite yet --- apps/tlon-mobile/src/App.main.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/tlon-mobile/src/App.main.tsx b/apps/tlon-mobile/src/App.main.tsx index 23dcef4e0a..936876fc94 100644 --- a/apps/tlon-mobile/src/App.main.tsx +++ b/apps/tlon-mobile/src/App.main.tsx @@ -66,8 +66,6 @@ const App = ({ const { lure, priorityToken } = useBranch(); const screenOptions = useScreenOptions(); - perf().startScreenTrace('App'); - useEffect(() => { const unsubscribeFromNetInfo = NetInfo.addEventListener( ({ isConnected }) => { From f8ecc3b993bb26ebc9c956bac7b2e0bc78f23655 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 28 Jun 2024 16:48:23 +0000 Subject: [PATCH 17/19] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 1ce3523000..5b8e6ce642 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-0v7.jehvc.r34gn.7rsbc.7giu4.6ghb0.glob' 0v7.jehvc.r34gn.7rsbc.7giu4.6ghb0] + glob-http+['https://bootstrap.urbit.org/glob-0v3.pmi8j.nk40n.g8lff.f2isk.9t9jm.glob' 0v3.pmi8j.nk40n.g8lff.f2isk.9t9jm] base+'groups' version+[6 0 2] website+'https://tlon.io' From 4654fa3ba4081a45161acfa7dc49ad14f7670eaa Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 28 Jun 2024 17:28:10 +0000 Subject: [PATCH 18/19] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 5b8e6ce642..5083554155 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-0v3.pmi8j.nk40n.g8lff.f2isk.9t9jm.glob' 0v3.pmi8j.nk40n.g8lff.f2isk.9t9jm] + glob-http+['https://bootstrap.urbit.org/glob-0v3.p7ova.k2cku.cnf3g.bgg7n.tn0b4.glob' 0v3.p7ova.k2cku.cnf3g.bgg7n.tn0b4] base+'groups' version+[6 0 2] website+'https://tlon.io' From 3593e2724b0fbc71766a043405c8e0229dbe48b7 Mon Sep 17 00:00:00 2001 From: Dan Brewster Date: Fri, 28 Jun 2024 13:44:49 -0400 Subject: [PATCH 19/19] native: optimize list rendering (#3690) --- .../ui/src/components/Channel/Scroller.tsx | 57 +++++++------ packages/ui/src/components/ChatList.tsx | 58 +++++++------ .../src/components/SwipableChatListItem.tsx | 82 ++++++++++++------- 3 files changed, 115 insertions(+), 82 deletions(-) diff --git a/packages/ui/src/components/Channel/Scroller.tsx b/packages/ui/src/components/Channel/Scroller.tsx index c78915625f..88172e0d32 100644 --- a/packages/ui/src/components/Channel/Scroller.tsx +++ b/packages/ui/src/components/Channel/Scroller.tsx @@ -461,6 +461,8 @@ function Scroller({ onScrollToIndexFailed={handleScrollToIndexFailed} inverted={inverted} initialNumToRender={10} + maxToRenderPerBatch={8} + windowSize={10} maintainVisibleContentPosition={maintainVisibleContentPositionConfig} numColumns={channelType === 'gallery' ? 2 : 1} style={style} @@ -500,7 +502,7 @@ function getPostId(post: db.Post) { return post.id; } -const ScrollerItem = ({ +const BaseScrollerItem = ({ item, index, showUnreadDivider, @@ -600,31 +602,34 @@ const ScrollerItem = ({ ); }; -const PressableMessage = forwardRef< - RNView, - PropsWithChildren<{ isActive: boolean }> ->(function PressableMessageComponent({ isActive, children }, ref) { - return isActive ? ( - // need the extra React Native View for ref measurement - - {children} - - ) : ( - // this fragment is necessary to avoid the TS error about not being able to - // return undefined - <>{children} - ); -}); +const ScrollerItem = React.memo(BaseScrollerItem); + +const PressableMessage = React.memo( + forwardRef>( + function PressableMessageComponent({ isActive, children }, ref) { + return isActive ? ( + // need the extra React Native View for ref measurement + + {children} + + ) : ( + // this fragment is necessary to avoid the TS error about not being able to + // return undefined + <>{children} + ); + } + ) +); const UnreadsButton = ({ onPress }: { onPress: () => void }) => { return ( diff --git a/packages/ui/src/components/ChatList.tsx b/packages/ui/src/components/ChatList.tsx index 26fa523b88..98badb15f5 100644 --- a/packages/ui/src/components/ChatList.tsx +++ b/packages/ui/src/components/ChatList.tsx @@ -1,7 +1,7 @@ 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 React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { NativeScrollEvent, NativeSyntheticEvent, @@ -55,20 +55,13 @@ export function ChatList({ const renderItem = useCallback( ({ item }: SectionListRenderItemInfo) => { - const listItemElement = ( + return ( ); - if (logic.isChannel(item)) { - return ( - {listItemElement} - ); - } - // Pending items not affected by swipe - return listItemElement; }, [onPressItem, onLongPressItem] ); @@ -141,7 +134,7 @@ export function ChatList({ keyExtractor={getChannelKey} stickySectionHeadersEnabled={false} renderItem={renderItem} - maxToRenderPerBatch={11} + maxToRenderPerBatch={6} initialNumToRender={11} windowSize={2} viewabilityConfig={viewabilityConfig} @@ -151,7 +144,20 @@ export function ChatList({ /> ); } -const ChatListItem = React.memo(function ChatListItemComponent({ + +const ChatListItem = React.memo(function ChatListItemComponent( + props: ListItemProps +) { + return logic.isChannel(props.model) ? ( + + + + ) : ( + + ); +}); + +const ChatListItemContent = React.memo(function ChatListItemContentComponent({ model, onPress, onLongPress, @@ -165,17 +171,20 @@ const ChatListItem = React.memo(function ChatListItemComponent({ onLongPress?.(model); }, [model, onLongPress]); + const groupModel: db.Group | null | undefined = useMemo(() => { + return logic.isChannel(model) && model.group + ? ({ + ...model.group, + unreadCount: model.unread?.count, + lastPost: model.lastPost, + lastChannel: model.title, + } as const) + : null; + }, [model]); + // if the chat list item is a group, it's pending if (logic.isGroup(model)) { - return ( - - ); + return ; } if (logic.isChannel(model)) { @@ -192,17 +201,12 @@ const ChatListItem = React.memo(function ChatListItemComponent({ {...props} /> ); - } else if (model.group) { + } else if (groupModel) { return ( ); diff --git a/packages/ui/src/components/SwipableChatListItem.tsx b/packages/ui/src/components/SwipableChatListItem.tsx index 3d23c361d1..3d424c5a1a 100644 --- a/packages/ui/src/components/SwipableChatListItem.tsx +++ b/packages/ui/src/components/SwipableChatListItem.tsx @@ -1,7 +1,7 @@ import * as db from '@tloncorp/shared/dist/db'; import * as store from '@tloncorp/shared/dist/store'; import * as Haptics from 'expo-haptics'; -import { +import React, { ComponentProps, PropsWithChildren, useCallback, @@ -18,19 +18,21 @@ import { XStack } from '../core'; import * as utils from '../utils'; import { Icon, IconType } from './Icon'; -export function SwipableChatRow( - props: PropsWithChildren<{ model: db.Channel; jailBroken?: boolean }> -) { +function BaseSwipableChatRow({ + model, + jailBroken, + children, +}: PropsWithChildren<{ model: db.Channel; jailBroken?: boolean }>) { const swipeableRef = useRef(null); const isMuted = useMemo(() => { - if (props.model.group) { - return props.model.group.volumeSettings?.isMuted ?? false; - } else if (props.model.type === 'dm' || props.model.type === 'groupDm') { - return props.model.volumeSettings?.isMuted ?? false; + if (model.group) { + return model.group.volumeSettings?.isMuted ?? false; + } else if (model.type === 'dm' || model.type === 'groupDm') { + return model.volumeSettings?.isMuted ?? false; } return false; - }, [props.model]); + }, [model]); // prevent color flicker when unmuting const [mutedState, setMutedState] = useState(isMuted); useEffect(() => { @@ -47,50 +49,68 @@ export function SwipableChatRow( utils.triggerHaptic('swipeAction'); switch (actionId) { case 'pin': - props.model.pin - ? store.unpinItem(props.model.pin) - : store.pinItem(props.model); + model.pin ? store.unpinItem(model.pin) : store.pinItem(model); break; case 'mute': - isMuted ? store.unmuteChat(props.model) : store.muteChat(props.model); + isMuted ? store.unmuteChat(model) : store.muteChat(model); break; default: break; } swipeableRef.current?.close(); }, - [props.model, isMuted] + [model, isMuted] ); - return ( - - props.jailBroken ? ( - - ) : null - } - renderRightActions={(progress, drag) => ( + const renderLeftActions = useCallback( + ( + progress: Animated.AnimatedInterpolation, + drag: Animated.AnimatedInterpolation + ) => { + return jailBroken ? ( + + ) : null; + }, + [jailBroken, model] + ); + + const renderRightActions = useCallback( + ( + progress: Animated.AnimatedInterpolation, + drag: Animated.AnimatedInterpolation + ) => { + return ( - )} + ); + }, + [handleAction, mutedState, model] + ); + + return ( + - {props.children} + {children} ); } -function LeftActions({ +export const SwipableChatRow = React.memo(BaseSwipableChatRow); + +function BaseLeftActions({ model, progress, drag, @@ -140,7 +160,9 @@ function LeftActions({ ); } -function RightActions({ +export const LeftActions = React.memo(BaseLeftActions); + +function BaseRightActions({ model, isMuted, progress, @@ -185,6 +207,8 @@ function RightActions({ ); } +export const RightActions = React.memo(BaseRightActions); + function Action( props: ComponentProps & { backgroundColor: ColorTokens;