import { type Dispatch } from 'redux'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { BackHandler, FlatList, Keyboard, type NativeEventSubscription, PixelRatio, StyleSheet, Text, View } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import { connect } from 'react-redux'; import * as mime from 'react-native-mime-types'; import { dequal } from 'dequal'; import { Q } from '@nozbe/watermelondb'; import { type EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context'; import { type SearchBarCommands } from 'react-native-screens'; import { Component, createRef } from 'react'; import database from '~/lib/database'; import I18n from '~/i18n'; import { headerLeftClose } from '~/lib/methods/helpers/navigation/headerActions'; import { searchHeaderOptions } from '~/lib/methods/helpers/navigation/searchHeaderOptions'; import DirectoryItem, { ROW_HEIGHT } from '~/containers/DirectoryItem'; import RowSeparator from '~/containers/NativeListRow/components/Separator'; import ServerItem from '~/containers/ServerItem'; import ActivityIndicator from '~/containers/ActivityIndicator'; import * as List from '~/containers/List'; import { themes } from '~/lib/constants/colors'; import { type TSupportedThemes, withTheme } from '~/theme'; import SafeAreaView from '~/containers/SafeAreaView'; import { getSubscriptionSearchClause } from '~/lib/database/utils'; import styles from './styles'; import { type IApplicationState, RootEnum, type TServerModel, type TSubscriptionModel } from '~/definitions'; import { type ShareInsideStackParamList } from '~/definitions/navigationTypes'; import { getRoomAvatar, hasNativeHeaderBar, isAndroid, isIOS } from '~/lib/methods/helpers'; import { getFilenameFromUri } from '~/lib/methods/helpers/getFilenameFromUri'; import { nativeHeaderContentInset } from '~/lib/methods/helpers/navigation'; import { showToast } from '~/lib/methods/helpers/showToast'; import { shareSetParams } from '~/actions/share'; import { appStart } from '~/actions/app'; import { isE2EEDisabledEncryptedRoom, isMissingRoomE2EEKey } from '~/lib/encryption/utils'; interface IFileToShare { filename: string; description: string; size: number; mime: string; path: string; } interface IState { searching: boolean; searchText: string; searchResults: TSubscriptionModel[]; chats: TSubscriptionModel[]; serversCount: number; attachments: IFileToShare[]; text: string; loading: boolean; serverInfo: TServerModel; } interface INavigationOption { navigation: NativeStackNavigationProp; } interface IShareListViewProps extends INavigationOption { server: string; connecting: boolean; isAuthenticated: boolean; token: string; userId: string; theme: TSupportedThemes; airGappedRestrictionRemainingDays: number | undefined; encryptionEnabled: boolean; shareExtensionParams: Record; dispatch: Dispatch; insets: EdgeInsets; } const getItemLayout = (_data: any, index: number) => { const rowHeight = PixelRatio.roundToNearestPixel(ROW_HEIGHT * PixelRatio.getFontScale()); return { length: rowHeight, offset: (rowHeight + StyleSheet.hairlineWidth) * index, index }; }; const keyExtractor = (item: TSubscriptionModel) => item.rid; class ShareListView extends Component { private unsubscribeFocus: (() => void) | undefined; private unsubscribeBlur: (() => void) | undefined; private backHandler: NativeEventSubscription | undefined; private searchBarRef = createRef(); constructor(props: IShareListViewProps) { super(props); this.state = { searching: false, searchText: '', searchResults: [], chats: [], serversCount: 0, attachments: [], text: props.shareExtensionParams.text, loading: true, serverInfo: {} as TServerModel }; this.setHeader(); if (isAndroid) { this.unsubscribeFocus = props.navigation.addListener( 'focus', () => (this.backHandler = BackHandler.addEventListener('hardwareBackPress', this.handleBackPress)) ); this.unsubscribeBlur = props.navigation.addListener('blur', () => this.backHandler?.remove()); } } async componentDidMount() { const { shareExtensionParams } = this.props; const { mediaUris } = shareExtensionParams; if (mediaUris) { try { const info = await Promise.all(mediaUris.split(',').map((uri: string) => FileSystem.getInfoAsync(uri))); const attachments = ( await Promise.all( info.map(async file => { if (!file.exists) { return null; } try { await FileSystem.readAsStringAsync(file.uri, { encoding: FileSystem.EncodingType.Base64, position: 0, length: 1 }); } catch { return null; } return { filename: getFilenameFromUri(file.uri) ?? file.uri.substring(file.uri.lastIndexOf('/') + 1), description: '', size: file.size, mime: mime.lookup(file.uri) || '', path: file.uri }; }) ) ).filter((file): file is IFileToShare => !!file); this.setState({ // text, attachments }); if (!attachments.length) { showToast(I18n.t('Share_no_valid_attachments')); this.closeShareExtension(); return; } } catch { showToast(I18n.t('Share_no_valid_attachments')); this.closeShareExtension(); return; } } this.getSubscriptions(); } componentDidUpdate(previousProps: IShareListViewProps, previousState: IState) { const { searching } = this.state; const { server, connecting, isAuthenticated } = this.props; if ( previousProps.server !== server || (previousProps.connecting !== connecting && !connecting) || (previousProps.isAuthenticated !== isAuthenticated && isAuthenticated) ) { this.getSubscriptions(); } if (previousProps.connecting !== connecting && connecting) { this.setState({ chats: [], searchResults: [], searching: false, searchText: '' }); } if (!hasNativeHeaderBar && previousState.searching !== searching) { this.setHeader(); } } shouldComponentUpdate(nextProps: IShareListViewProps, nextState: IState) { const { searching } = this.state; if (nextState.searching !== searching) { return true; } const { server, userId, isAuthenticated, connecting, encryptionEnabled } = this.props; if (server !== nextProps.server) { return true; } if (userId !== nextProps.userId) { return true; } if (isAuthenticated !== nextProps.isAuthenticated) { return true; } if (connecting !== nextProps.connecting) { return true; } if (encryptionEnabled !== nextProps.encryptionEnabled) { return true; } const { searchResults } = this.state; if (nextState.searching) { if (!dequal(nextState.searchResults, searchResults)) { return true; } } return false; } componentWillUnmount() { if (this.unsubscribeFocus) { this.unsubscribeFocus(); } if (this.unsubscribeBlur) { this.unsubscribeBlur(); } const { dispatch } = this.props; dispatch(shareSetParams({})); } setHeader = () => { const { searching } = this.state; const { navigation } = this.props; const options: NativeStackNavigationOptions = { ...headerLeftClose(this.closeShareExtension, 'share-extension-close'), headerTitle: I18n.t('Send_to') }; navigation.setOptions( this.airGappedReadOnly ? options : searchHeaderOptions({ isSearching: searching, searchBarRef: this.searchBarRef, onSearchPress: this.initSearch, onChangeText: this.search, onCancel: this.cancelSearch, testIDPrefix: 'share-list-view', options }) ); }; query = async (text?: string) => { const db = database.active; const defaultWhereClause = [ Q.where('archived', false), Q.where('open', true), Q.skip(0), Q.take(20), Q.sortBy('room_updated_at', Q.desc) ] as (Q.WhereDescription | Q.Skip | Q.Take | Q.SortBy | Q.Or)[]; if (text) { defaultWhereClause.push(getSubscriptionSearchClause(text)); } const data = (await db .get('subscriptions') .query(...defaultWhereClause) .fetch()) as TSubscriptionModel[]; return data.map(item => ({ rid: item.rid, t: item.t, name: item.name, fname: item.fname, blocked: item.blocked, blocker: item.blocker, prid: item.prid, uids: item.uids, usernames: item.usernames, topic: item.topic, teamMain: item.teamMain, encrypted: item.encrypted, E2EKey: item.E2EKey })); }; getSubscriptions = async () => { const { server, connecting, isAuthenticated } = this.props; if (connecting || !isAuthenticated) { return; } if (server) { const chats = (await this.query()) as TSubscriptionModel[]; const serversDB = database.servers; const serversCollection = serversDB.get('servers'); const serversCount = await serversCollection.query(Q.where('rooms_updated_at', Q.notEq(null))).fetchCount(); let serverInfo = {} as TServerModel; try { serverInfo = await serversCollection.find(server); } catch (error) { // Do nothing } if (this.airGappedReadOnly) { this.setState({ chats: [], serversCount, loading: false, serverInfo }); this.forceUpdate(); return; } this.setState({ chats: chats ?? [], serversCount, loading: false, serverInfo }); this.forceUpdate(); } }; uriToPath = (uri: string) => decodeURIComponent(isIOS ? uri.replace(/^file:\/\//, '') : uri); getRoomTitle = (item: TSubscriptionModel) => { const { serverInfo } = this.state; return ((item.prid || serverInfo?.useRealName) && item.fname) || item.name; }; shareMessage = (room: TSubscriptionModel) => { const { attachments, text, serverInfo } = this.state; const { navigation, shareExtensionParams } = this.props; if (shareExtensionParams?.mediaUris && !attachments.length) { showToast(I18n.t('Share_no_valid_attachments')); return; } navigation.navigate('ShareView', { room, text, attachments, serverInfo, isShareExtension: true }); }; search = async (text: string) => { const result = (await this.query(text)) as TSubscriptionModel[]; this.setState({ searchResults: result, searchText: text }); }; initSearch = () => { const { chats } = this.state; this.setState({ searching: true, searchResults: chats }, () => this.setHeader()); }; cancelSearch = () => { this.setState({ searching: false, searchResults: [], searchText: '' }, () => { this.setHeader(); }); Keyboard.dismiss(); }; handleBackPress = () => { const { searching } = this.state; if (searching) { this.cancelSearch(); return true; } return false; }; closeShareExtension = () => { const { dispatch } = this.props; dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); }; get airGappedReadOnly() { const { airGappedRestrictionRemainingDays } = this.props; return airGappedRestrictionRemainingDays !== undefined && airGappedRestrictionRemainingDays === 0; } renderSectionHeader = (header: string) => { const { searching } = this.state; const { theme } = this.props; if (searching) { return null; } return ( <> {I18n.t(header)} {isIOS ? null : } ); }; getUnavailableReason = (item: TSubscriptionModel) => { const { encryptionEnabled } = this.props; if (isE2EEDisabledEncryptedRoom({ encryptionEnabled, roomEncrypted: item.encrypted })) { return I18n.t('Share_encrypted_room_password_required'); } if (isMissingRoomE2EEKey({ encryptionEnabled, roomEncrypted: item.encrypted, E2EKey: item.E2EKey })) { return I18n.t('Share_encrypted_room_key_missing'); } }; renderItem = ({ item, index }: { item: TSubscriptionModel; index: number }) => { const { serverInfo, chats, searchResults, searching } = this.state; const rowCount = searching ? searchResults.length : chats.length; const unavailableReason = this.getUnavailableReason(item); let description; switch (item.t) { case 'c': description = item.topic || item.description; break; case 'p': description = item.topic || item.description; break; case 'd': description = serverInfo?.useRealName ? item.name : item.fname; break; default: description = item.fname; break; } return ( this.shareMessage(item)} disabled={!!unavailableReason} testID={`share-extension-item-${item.name}`} teamMain={item.teamMain} isFirst={index === 0} isLast={index === rowCount - 1} /> ); }; renderSelectServer = () => { const { serverInfo, serversCount } = this.state; const { navigation } = this.props; if (serversCount === 1) { return null; } return ( <> {this.renderSectionHeader('Select_Server')} navigation.navigate('SelectServerView')} item={serverInfo} /> {isIOS ? null : } ); }; renderEmptyComponent = () => { const { searching, searchText } = this.state; const { theme } = this.props; if (searching && searchText) { return ( {I18n.t('No_results_found')} ); } return null; }; renderHeader = () => { const { searching } = this.state; if (searching) { return null; } return ( <> {this.renderSelectServer()} {this.renderSectionHeader('Chats')} ); }; render = () => { const { chats, loading, searchResults, searching, serversCount } = this.state; const { theme, insets, encryptionEnabled } = this.props; if (loading) { return ; } if (this.airGappedReadOnly) { return ( {this.renderSelectServer()} {serversCount > 1 ? ( <> ) : null} 1 ? { justifyContent: 'center' } : { paddingTop: 250 }) } ]}> {I18n.t('AirGapped_workspace_read_only_share_extension_title')} {I18n.t('AirGapped_workspace_read_only_description')} ); } return ( 0) ? : null} ListEmptyComponent={this.renderEmptyComponent} removeClippedSubviews keyboardShouldPersistTaps='always' /> ); }; } const mapStateToProps = ({ login, server, share, settings, encryption }: IApplicationState) => ({ userId: login?.user?.id as string, token: login?.user?.token as string, isAuthenticated: login?.isAuthenticated, server: server?.server, connecting: server?.connecting, shareExtensionParams: share?.params, encryptionEnabled: encryption.enabled, airGappedRestrictionRemainingDays: typeof settings.Cloud_Workspace_AirGapped_Restrictions_Remaining_Days === 'number' ? settings.Cloud_Workspace_AirGapped_Restrictions_Remaining_Days : undefined }); export { ShareListView }; export default connect(mapStateToProps)(withTheme(withSafeAreaInsets(ShareListView)));