diff --git a/lib/components/debug-logs-context-provider.react.js b/lib/components/debug-logs-context-provider.react.js new file mode 100644 --- /dev/null +++ b/lib/components/debug-logs-context-provider.react.js @@ -0,0 +1,45 @@ +// @flow + +import * as React from 'react'; + +import { type DebugLog, DebugLogsContext } from './debug-logs-context.js'; + +type Props = { + +children: React.Node, +}; + +function DebugLogsContextProvider(props: Props): React.Node { + const [logs, setLogs] = React.useState<$ReadOnlyArray<DebugLog>>([]); + + const addLog = React.useCallback( + (title: string, message: string) => + setLogs(prevLogs => [ + ...prevLogs, + { + title, + message, + timestamp: Date.now(), + }, + ]), + [], + ); + + const clearLogs = React.useCallback(() => setLogs([]), []); + + const contextValue = React.useMemo( + () => ({ + logs, + addLog, + clearLogs, + }), + [addLog, clearLogs, logs], + ); + + return ( + <DebugLogsContext.Provider value={contextValue}> + {props.children} + </DebugLogsContext.Provider> + ); +} + +export { DebugLogsContextProvider }; diff --git a/lib/components/debug-logs-context.js b/lib/components/debug-logs-context.js new file mode 100644 --- /dev/null +++ b/lib/components/debug-logs-context.js @@ -0,0 +1,31 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +export type DebugLog = { + +title: string, + +message: string, + +timestamp: number, +}; + +export type DebugLogsContextType = { + +logs: $ReadOnlyArray<DebugLog>, + +addLog: (title: string, message: string) => mixed, + +clearLogs: () => mixed, +}; + +const DebugLogsContext: React.Context<DebugLogsContextType> = + React.createContext<DebugLogsContextType>({ + logs: [], + addLog: () => {}, + clearLogs: () => {}, + }); + +function useDebugLogs(): DebugLogsContextType { + const debugLogsContext = React.useContext(DebugLogsContext); + invariant(debugLogsContext, 'Debug logs context should be present'); + return debugLogsContext; +} + +export { DebugLogsContext, useDebugLogs }; diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -188,6 +188,7 @@ export const SecondaryDeviceConnectedRouteName = 'SecondaryDeviceConnected'; export const SecondaryDeviceNotRespondingRouteName = 'SecondaryDeviceNotResponding'; +export const DebugLogsScreenRouteName = 'DebugLogsScreen'; export type RootParamList = { +LoggedOutModal: void, @@ -304,6 +305,7 @@ +KeyserverSelectionList: void, +AddKeyserver: void, +FarcasterAccountSettings: void, + +DebugLogsScreen: void, }; export type CalendarParamList = { diff --git a/native/profile/debug-logs-screen.react.js b/native/profile/debug-logs-screen.react.js new file mode 100644 --- /dev/null +++ b/native/profile/debug-logs-screen.react.js @@ -0,0 +1,82 @@ +// @flow + +import Clipboard from '@react-native-clipboard/clipboard'; +import * as React from 'react'; +import { FlatList, View, Text } from 'react-native'; + +import { + useDebugLogs, + type DebugLog, +} from 'lib/components/debug-logs-context.js'; + +import type { ProfileNavigationProp } from './profile.react.js'; +import PrimaryButton from '../components/primary-button.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; +import { useStyles } from '../themes/colors.js'; + +type Props = { + +navigation: ProfileNavigationProp<'DebugLogsScreen'>, + +route: NavigationRoute<'DebugLogsScreen'>, +}; + +// eslint-disable-next-line no-unused-vars +function DebugLogsScreen(props: Props): React.Node { + const { logs, clearLogs } = useDebugLogs(); + + const copyLogs = React.useCallback(() => { + Clipboard.setString(JSON.stringify(logs)); + }, [logs]); + + const styles = useStyles(unboundStyles); + + const renderItem = React.useCallback( + ({ item }: { +item: DebugLog, ... }) => { + const date = new Date(item.timestamp); + const timestampString = date.toISOString(); + return ( + <View style={styles.item}> + <Text style={styles.timestamp}>{timestampString}</Text> + <Text style={styles.title}>{item.title}</Text> + <Text style={styles.message}>{item.message}</Text> + </View> + ); + }, + [styles.item, styles.message, styles.timestamp, styles.title], + ); + + return ( + <View style={styles.view}> + <FlatList data={logs} renderItem={renderItem} /> + <PrimaryButton onPress={clearLogs} variant="danger" label="Clear Logs" /> + <PrimaryButton onPress={copyLogs} variant="enabled" label="Copy Logs" /> + </View> + ); +} + +const unboundStyles = { + view: { + flex: 1, + backgroundColor: 'panelBackground', + padding: 24, + }, + item: { + backgroundColor: 'panelForeground', + borderWidth: 1, + borderTopWidth: 1, + borderColor: 'panelForegroundBorder', + marginBottom: 8, + padding: 8, + }, + timestamp: { + color: 'panelForegroundLabel', + }, + title: { + color: 'panelForegroundLabel', + fontSize: 16, + }, + message: { + color: 'panelForegroundSecondaryLabel', + }, +}; + +export default DebugLogsScreen; diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-screen.react.js @@ -57,6 +57,7 @@ KeyserverSelectionListRouteName, TunnelbrokerMenuRouteName, FarcasterAccountSettingsRouteName, + DebugLogsScreenRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; @@ -199,7 +200,8 @@ let developerTools, defaultNotifications, keyserverSelection, - tunnelbrokerMenu; + tunnelbrokerMenu, + debugLogs; const { staffCanSee } = this.props; if (staffCanSee) { developerTools = ( @@ -226,6 +228,10 @@ onPress={this.onPressTunnelbrokerMenu} /> ); + + debugLogs = ( + <ProfileRow content="Debug logs" onPress={this.onPressDebugLogs} /> + ); } let backupMenu; @@ -342,6 +348,7 @@ <ProfileRow content="Build info" onPress={this.onPressBuildInfo} /> {developerTools} {dmActions} + {debugLogs} </View> <View style={this.props.styles.unpaddedSection}> <ProfileRow @@ -546,6 +553,10 @@ onPressCreateThread = () => { void this.props.onCreateDMThread(); }; + + onPressDebugLogs = () => { + this.props.navigation.navigate({ name: DebugLogsScreenRouteName }); + }; } const logOutLoadingStatusSelector = diff --git a/native/profile/profile.react.js b/native/profile/profile.react.js --- a/native/profile/profile.react.js +++ b/native/profile/profile.react.js @@ -14,6 +14,7 @@ import AppearancePreferences from './appearance-preferences.react.js'; import BackupMenu from './backup-menu.react.js'; import BuildInfo from './build-info.react.js'; +import DebugLogsScreen from './debug-logs-screen.react.js'; import DefaultNotificationsPreferences from './default-notifications-preferences.react.js'; import DeleteAccount from './delete-account.react.js'; import DevTools from './dev-tools.react.js'; @@ -52,6 +53,7 @@ type ScreenParamList, type ProfileParamList, TunnelbrokerMenuRouteName, + DebugLogsScreenRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useStyles, useColors } from '../themes/colors.js'; @@ -83,6 +85,7 @@ const blockListOptions = { headerTitle: 'Block list' }; const defaultNotificationsOptions = { headerTitle: 'Default Notifications' }; const farcasterSettingsOptions = { headerTitle: 'Farcaster account' }; +const debugLogsScreenOptions = { headerTitle: 'Logs' }; export type ProfileNavigationProp< RouteName: $Keys<ProfileParamList> = $Keys<ProfileParamList>, @@ -222,6 +225,11 @@ component={FarcasterAccountSettings} options={farcasterSettingsOptions} /> + <Profile.Screen + name={DebugLogsScreenRouteName} + component={DebugLogsScreen} + options={debugLogsScreenOptions} + /> </Profile.Navigator> </KeyboardAvoidingView> </View> diff --git a/native/root.react.js b/native/root.react.js --- a/native/root.react.js +++ b/native/root.react.js @@ -24,6 +24,7 @@ import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; +import { DebugLogsContextProvider } from 'lib/components/debug-logs-context-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; import { FarcasterChannelPrefetchHandler } from 'lib/components/farcaster-channel-prefetch-handler.react.js'; @@ -334,95 +335,97 @@ ); } return ( - <GestureHandlerRootView style={styles.app}> - <StaffContextProvider> - <IdentityServiceContextProvider> - <UserIdentityCacheProvider> - <ENSCacheProvider - ethersProvider={ethersProvider} - alchemyKey={alchemyKey} - > - <NeynarClientProvider apiKey={neynarKey}> - <TunnelbrokerProvider> - <IdentitySearchProvider> - <SecondaryDeviceQRAuthContextProvider - parseTunnelbrokerQRAuthMessage={ - parseTunnelbrokerQRAuthMessage - } - composeTunnelbrokerQRAuthMessage={ - composeTunnelbrokerQRAuthMessage - } - generateAESKey={generateQRAuthAESKey} - performBackupRestore={performBackupRestore} - onLogInError={handleSecondaryDeviceLogInError} - > - <FeatureFlagsProvider> - <NavContext.Provider value={navContext}> - <RootContext.Provider value={rootContext}> - <InputStateContainer> - <MessageEditingContextProvider> - <SafeAreaProvider - initialMetrics={initialWindowMetrics} - > - <ActionSheetProvider> - <MediaCacheProvider - persistence={filesystemMediaCache} - > - <EditUserAvatarProvider> - <NativeEditThreadAvatarProvider> - <MarkdownContextProvider> - <MessageSearchProvider> - <BottomSheetProvider> - <RegistrationContextProvider> - <SQLiteDataHandler /> - <ConnectedStatusBar /> - <ReduxPersistGate - persistor={getPersistor()} - > - {gated} - </ReduxPersistGate> - <PersistedStateGate> - <KeyserverConnectionsHandler - socketComponent={Socket} - detectUnsupervisedBackgroundRef={ - detectUnsupervisedBackgroundRef - } - /> - <DMActivityHandler /> - <VersionSupportedChecker /> - <PlatformDetailsSynchronizer /> - <BackgroundIdentityLoginHandler /> - <PrekeysHandler /> - <ReportHandler /> - <FarcasterChannelPrefetchHandler /> - <AutoJoinCommunityHandler /> - <SyncCommunityStoreHandler /> - <InitialStateSharingHandler /> - </PersistedStateGate> - {navigation} - </RegistrationContextProvider> - </BottomSheetProvider> - </MessageSearchProvider> - </MarkdownContextProvider> - </NativeEditThreadAvatarProvider> - </EditUserAvatarProvider> - </MediaCacheProvider> - </ActionSheetProvider> - </SafeAreaProvider> - </MessageEditingContextProvider> - </InputStateContainer> - </RootContext.Provider> - </NavContext.Provider> - </FeatureFlagsProvider> - </SecondaryDeviceQRAuthContextProvider> - </IdentitySearchProvider> - </TunnelbrokerProvider> - </NeynarClientProvider> - </ENSCacheProvider> - </UserIdentityCacheProvider> - </IdentityServiceContextProvider> - </StaffContextProvider> - </GestureHandlerRootView> + <DebugLogsContextProvider> + <GestureHandlerRootView style={styles.app}> + <StaffContextProvider> + <IdentityServiceContextProvider> + <UserIdentityCacheProvider> + <ENSCacheProvider + ethersProvider={ethersProvider} + alchemyKey={alchemyKey} + > + <NeynarClientProvider apiKey={neynarKey}> + <TunnelbrokerProvider> + <IdentitySearchProvider> + <SecondaryDeviceQRAuthContextProvider + parseTunnelbrokerQRAuthMessage={ + parseTunnelbrokerQRAuthMessage + } + composeTunnelbrokerQRAuthMessage={ + composeTunnelbrokerQRAuthMessage + } + generateAESKey={generateQRAuthAESKey} + performBackupRestore={performBackupRestore} + onLogInError={handleSecondaryDeviceLogInError} + > + <FeatureFlagsProvider> + <NavContext.Provider value={navContext}> + <RootContext.Provider value={rootContext}> + <InputStateContainer> + <MessageEditingContextProvider> + <SafeAreaProvider + initialMetrics={initialWindowMetrics} + > + <ActionSheetProvider> + <MediaCacheProvider + persistence={filesystemMediaCache} + > + <EditUserAvatarProvider> + <NativeEditThreadAvatarProvider> + <MarkdownContextProvider> + <MessageSearchProvider> + <BottomSheetProvider> + <RegistrationContextProvider> + <SQLiteDataHandler /> + <ConnectedStatusBar /> + <ReduxPersistGate + persistor={getPersistor()} + > + {gated} + </ReduxPersistGate> + <PersistedStateGate> + <KeyserverConnectionsHandler + socketComponent={Socket} + detectUnsupervisedBackgroundRef={ + detectUnsupervisedBackgroundRef + } + /> + <DMActivityHandler /> + <VersionSupportedChecker /> + <PlatformDetailsSynchronizer /> + <BackgroundIdentityLoginHandler /> + <PrekeysHandler /> + <ReportHandler /> + <FarcasterChannelPrefetchHandler /> + <AutoJoinCommunityHandler /> + <SyncCommunityStoreHandler /> + <InitialStateSharingHandler /> + </PersistedStateGate> + {navigation} + </RegistrationContextProvider> + </BottomSheetProvider> + </MessageSearchProvider> + </MarkdownContextProvider> + </NativeEditThreadAvatarProvider> + </EditUserAvatarProvider> + </MediaCacheProvider> + </ActionSheetProvider> + </SafeAreaProvider> + </MessageEditingContextProvider> + </InputStateContainer> + </RootContext.Provider> + </NavContext.Provider> + </FeatureFlagsProvider> + </SecondaryDeviceQRAuthContextProvider> + </IdentitySearchProvider> + </TunnelbrokerProvider> + </NeynarClientProvider> + </ENSCacheProvider> + </UserIdentityCacheProvider> + </IdentityServiceContextProvider> + </StaffContextProvider> + </GestureHandlerRootView> + </DebugLogsContextProvider> ); } diff --git a/web/root.js b/web/root.js --- a/web/root.js +++ b/web/root.js @@ -11,6 +11,7 @@ import thunk from 'redux-thunk'; import { WagmiProvider } from 'wagmi'; +import { DebugLogsContextProvider } from 'lib/components/debug-logs-context-provider.react.js'; import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import PrekeysHandler from 'lib/components/prekeys-handler.react.js'; import ReportHandler from 'lib/components/report-handler.react.js'; @@ -62,25 +63,27 @@ const RootProvider = (): React.Node => ( <Provider store={store}> <ErrorBoundary> - <WagmiProvider config={wagmiConfig}> - <QueryClientProvider client={queryClient}> - <CallKeyserverEndpointProvider> - <InitialReduxStateGate persistor={persistor}> - <IdentityServiceContextProvider> - <UserIdentityCacheProvider> - <Router history={history.getHistoryObject()}> - <Route path="*" component={App} /> - </Router> - <PrekeysHandler /> - <SQLiteDataHandler /> - <IntegrityHandler /> - <ReportHandler canSendReports={true} /> - </UserIdentityCacheProvider> - </IdentityServiceContextProvider> - </InitialReduxStateGate> - </CallKeyserverEndpointProvider> - </QueryClientProvider> - </WagmiProvider> + <DebugLogsContextProvider> + <WagmiProvider config={wagmiConfig}> + <QueryClientProvider client={queryClient}> + <CallKeyserverEndpointProvider> + <InitialReduxStateGate persistor={persistor}> + <IdentityServiceContextProvider> + <UserIdentityCacheProvider> + <Router history={history.getHistoryObject()}> + <Route path="*" component={App} /> + </Router> + <PrekeysHandler /> + <SQLiteDataHandler /> + <IntegrityHandler /> + <ReportHandler canSendReports={true} /> + </UserIdentityCacheProvider> + </IdentityServiceContextProvider> + </InitialReduxStateGate> + </CallKeyserverEndpointProvider> + </QueryClientProvider> + </WagmiProvider> + </DebugLogsContextProvider> </ErrorBoundary> </Provider> ); diff --git a/web/settings/account-settings.react.js b/web/settings/account-settings.react.js --- a/web/settings/account-settings.react.js +++ b/web/settings/account-settings.react.js @@ -32,6 +32,7 @@ import css from './account-settings.css'; import AppearanceChangeModal from './appearance-change-modal.react.js'; import BackupTestRestoreModal from './backup-test-restore-modal.react.js'; +import DebugLogsModal from './debug-logs-modal.react.js'; import BlockListModal from './relationship/block-list-modal.react.js'; import FriendListModal from './relationship/friend-list-modal.react.js'; import TunnelbrokerMessagesScreen from './tunnelbroker-message-list.react.js'; @@ -178,6 +179,11 @@ [pushModal], ); + const openDebugLogs = React.useCallback( + () => pushModal(<DebugLogsModal />), + [pushModal], + ); + if (!currentUserInfo || currentUserInfo.anonymous) { return null; } @@ -294,6 +300,25 @@ ); } + let debugLogs; + if (staffCanSee) { + debugLogs = ( + <div className={css.preferencesContainer}> + <h4 className={css.preferencesHeader}>Debug menu</h4> + <div className={css.content}> + <ul> + <li> + <span>See debug logs</span> + <Button variant="text" onClick={openDebugLogs}> + <p className={css.buttonText}>See List</p> + </Button> + </li> + </ul> + </div> + </div> + ); + } + return ( <div className={css.container}> <div className={css.contentContainer}> @@ -329,6 +354,7 @@ {backup} {deviceData} {dms} + {debugLogs} </div> </div> ); diff --git a/web/settings/debug-logs-modal.css b/web/settings/debug-logs-modal.css new file mode 100644 --- /dev/null +++ b/web/settings/debug-logs-modal.css @@ -0,0 +1,47 @@ +.container { + display: flex; + flex-direction: column; + height: 60vh; + gap: 8px; +} + +.logsList { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: scroll; +} + +.item { + margin: 4px 0; + padding: 4px 0; + color: var(--text-background-secondary-default); + transition: 0.1s; +} + +.item:hover { + background-color: var(--modal-listItem-primary-hover); +} + +.timestamp { + font-size: var(--m-font-16); +} + +.title { + font-size: var(--l-font-18); + color: var(--text-background-primary-default); +} + +.message { + font-size: var(--m-font-16); +} + +.buttons { + display: flex; + gap: 8px; + justify-content: space-between; +} + +.button { + flex: 1; +} diff --git a/web/settings/debug-logs-modal.react.js b/web/settings/debug-logs-modal.react.js new file mode 100644 --- /dev/null +++ b/web/settings/debug-logs-modal.react.js @@ -0,0 +1,58 @@ +// @flow + +import * as React from 'react'; + +import { useDebugLogs } from 'lib/components/debug-logs-context.js'; +import { useModalContext } from 'lib/components/modal-provider.react.js'; + +import css from './debug-logs-modal.css'; +import Button, { buttonThemes } from '../components/button.react.js'; +import Modal from '../modals/modal.react.js'; + +function DebugLogsModal(): React.Node { + const { logs, clearLogs } = useDebugLogs(); + const { popModal } = useModalContext(); + + const messageList = React.useMemo( + () => + logs.map((item, index) => { + const date = new Date(item.timestamp); + const timestampString = date.toISOString(); + return ( + <div key={index} className={css.item}> + <div className={css.timestamp}>{timestampString}</div> + <div className={css.title}>{item.title}</div> + <div className={css.message}>{item.message}</div> + </div> + ); + }), + [logs], + ); + + const copyLogs = React.useCallback(async () => { + await navigator.clipboard.writeText(JSON.stringify(logs)); + }, [logs]); + + return ( + <Modal name="Debug Logs" onClose={popModal} size="large"> + <div className={css.container}> + <div className={css.logsList}>{messageList}</div> + <div className={css.buttons}> + <Button variant="filled" className={css.button} onClick={copyLogs}> + Copy Logs + </Button> + <Button + variant="filled" + buttonColor={buttonThemes.danger} + className={css.button} + onClick={clearLogs} + > + Clear Logs + </Button> + </div> + </div> + </Modal> + ); +} + +export default DebugLogsModal;