diff --git a/lib/types/media-types.js b/lib/types/media-types.js index 83bf6f655..bf4161260 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,527 +1,525 @@ // @flow import type { Shape } from './core.js'; import { type Platform } from './device-types.js'; export type Dimensions = $ReadOnly<{ +height: number, +width: number, }>; export type MediaType = 'photo' | 'video'; export type Image = { +id: string, +uri: string, +type: 'photo', +dimensions: Dimensions, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export type Video = { +id: string, +uri: string, +type: 'video', +dimensions: Dimensions, +loop?: boolean, +thumbnailID: string, +thumbnailURI: string, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export type Media = Image | Video; export type ClientDBMediaInfo = { +id: string, +uri: string, +type: 'photo' | 'video', +extras: string, }; export type Corners = Shape<{ +topLeft: boolean, +topRight: boolean, +bottomLeft: boolean, +bottomRight: boolean, }>; export type MediaInfo = | { ...Image, - +corners: Corners, +index: number, } | { ...Video, - +corners: Corners, +index: number, }; export type UploadMultimediaResult = { +id: string, +uri: string, +dimensions: Dimensions, +mediaType: MediaType, +loop: boolean, }; export type UpdateMultimediaMessageMediaPayload = { +messageID: string, +currentMediaID: string, +mediaUpdate: Shape, }; export type UploadDeletionRequest = { +id: string, }; export type FFmpegStatistics = { // seconds of video being processed per second +speed: number, // total milliseconds of video processed so far +time: number, // total result file size in bytes so far +size: number, +videoQuality: number, +videoFrameNumber: number, +videoFps: number, +bitrate: number, }; export type TranscodeVideoMediaMissionStep = { +step: 'video_ffmpeg_transcode', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +returnCode: ?number, +newPath: ?string, +stats: ?FFmpegStatistics, }; export type VideoGenerateThumbnailMediaMissionStep = { +step: 'video_generate_thumbnail', +success: boolean, +time: number, // ms +returnCode: number, +thumbnailURI: string, }; export type VideoInfo = { +codec: ?string, +dimensions: ?Dimensions, +duration: number, // seconds +format: $ReadOnlyArray, }; export type VideoProbeMediaMissionStep = { +step: 'video_probe', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +validFormat: boolean, +duration: ?number, // seconds +codec: ?string, +format: ?$ReadOnlyArray, +dimensions: ?Dimensions, }; export type ReadFileHeaderMediaMissionStep = { +step: 'read_file_header', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +mime: ?string, +mediaType: ?MediaType, }; export type DetermineFileTypeMediaMissionStep = { +step: 'determine_file_type', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMIME: ?string, +outputMediaType: ?MediaType, +outputFilename: ?string, }; export type FrameCountMediaMissionStep = { +step: 'frame_count', +success: boolean, +exceptionMessage: ?string, +time: number, +path: string, +mime: string, +hasMultipleFrames: ?boolean, }; export type DisposeTemporaryFileMediaMissionStep = { +step: 'dispose_temporary_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type MakeDirectoryMediaMissionStep = { +step: 'make_directory', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type AndroidScanFileMediaMissionStep = { +step: 'android_scan_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type FetchFileHashMediaMissionStep = { +step: 'fetch_file_hash', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +hash: ?string, }; export type CopyFileMediaMissionStep = { +step: 'copy_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +source: string, +destination: string, }; export type GetOrientationMediaMissionStep = { +step: 'exif_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +orientation: ?number, }; export type MediaLibrarySelection = | { +step: 'photo_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, } | { +step: 'video_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, +duration: number, // seconds }; export type PhotoCapture = { +step: 'photo_capture', +time: number, // ms +dimensions: Dimensions, +filename: string, +uri: string, +captureTime: number, // ms timestamp +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export type PhotoPaste = { +step: 'photo_paste', +dimensions: Dimensions, +filename: string, +uri: string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export type NativeMediaSelection = | MediaLibrarySelection | PhotoCapture | PhotoPaste; export type MediaMissionStep = | NativeMediaSelection | { +step: 'web_selection', +filename: string, +size: number, // in bytes +mime: string, +selectTime: number, // ms timestamp } | { +step: 'asset_info_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +localURI: ?string, +orientation: ?number, } | { +step: 'stat_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +fileSize: ?number, } | ReadFileHeaderMediaMissionStep | DetermineFileTypeMediaMissionStep | FrameCountMediaMissionStep | { +step: 'photo_manipulation', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +manipulation: Object, +newMIME: ?string, +newDimensions: ?Dimensions, +newURI: ?string, } | VideoProbeMediaMissionStep | TranscodeVideoMediaMissionStep | VideoGenerateThumbnailMediaMissionStep | DisposeTemporaryFileMediaMissionStep | { +step: 'save_media', +uri: string, +time: number, // ms timestamp } | { +step: 'permissions_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +platform: Platform, +permissions: $ReadOnlyArray, } | MakeDirectoryMediaMissionStep | AndroidScanFileMediaMissionStep | { +step: 'ios_save_to_library', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, } | { +step: 'fetch_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputURI: string, +uri: string, +size: ?number, +mime: ?string, } | { +step: 'data_uri_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +first255Chars: ?string, } | { +step: 'array_buffer_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms } | { +step: 'mime_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +mime: ?string, } | { +step: 'write_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +length: number, } | FetchFileHashMediaMissionStep | CopyFileMediaMissionStep | GetOrientationMediaMissionStep | { +step: 'preload_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +dimensions: ?Dimensions, } | { +step: 'reorient_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: ?string, } | { +step: 'upload', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMediaType: ?MediaType, +outputURI: ?string, +outputDimensions: ?Dimensions, +outputLoop: ?boolean, +hasWiFi?: boolean, } | { +step: 'wait_for_capture_uri_unload', +success: boolean, +time: number, // ms +uri: string, }; export type MediaMissionFailure = | { +success: false, +reason: 'no_file_path', } | { +success: false, +reason: 'file_stat_failed', +uri: string, } | { +success: false, +reason: 'photo_manipulation_failed', +size: number, // in bytes } | { +success: false, +reason: 'media_type_fetch_failed', +detectedMIME: ?string, } | { +success: false, +reason: 'mime_type_mismatch', +reportedMediaType: MediaType, +reportedMIME: string, +detectedMIME: string, } | { +success: false, +reason: 'http_upload_failed', +exceptionMessage: ?string, } | { +success: false, +reason: 'video_too_long', +duration: number, // in seconds } | { +success: false, +reason: 'video_probe_failed', } | { +success: false, +reason: 'video_transcode_failed', } | { +success: false, +reason: 'video_generate_thumbnail_failed', } | { +success: false, +reason: 'processing_exception', +time: number, // ms +exceptionMessage: ?string, } | { +success: false, +reason: 'save_unsupported', } | { +success: false, +reason: 'missing_permission', } | { +success: false, +reason: 'make_directory_failed', } | { +success: false, +reason: 'resolve_failed', +uri: string, } | { +success: false, +reason: 'save_to_library_failed', +uri: string, } | { +success: false, +reason: 'fetch_failed', } | { +success: false, +reason: 'data_uri_failed', } | { +success: false, +reason: 'array_buffer_failed', } | { +success: false, +reason: 'mime_check_failed', +mime: ?string, } | { +success: false, +reason: 'write_file_failed', } | { +success: false, +reason: 'fetch_file_hash_failed', } | { +success: false, +reason: 'copy_file_failed', } | { +success: false, +reason: 'exif_fetch_failed', } | { +success: false, +reason: 'reorient_image_failed', } | { +success: false, +reason: 'web_sibling_validation_failed', }; export type MediaMissionResult = MediaMissionFailure | { +success: true }; export type MediaMission = { +steps: $ReadOnlyArray, +result: MediaMissionResult, +userTime: number, +totalTime: number, }; diff --git a/native/chat/inner-multimedia-message.react.js b/native/chat/inner-multimedia-message.react.js index 2661d8818..7af250fbe 100644 --- a/native/chat/inner-multimedia-message.react.js +++ b/native/chat/inner-multimedia-message.react.js @@ -1,167 +1,166 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import type { Corners, Media, MediaInfo } from 'lib/types/media-types.js'; import MultimediaMessageMultimedia from './multimedia-message-multimedia.react.js'; import { getMediaPerRow, spaceBetweenImages, } from './multimedia-message-utils.js'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import type { ViewStyle } from '../types/styles.js'; const borderRadius = 16; type Props = { +item: ChatMultimediaMessageInfoItem, +verticalBounds: ?VerticalBounds, +onPressMultimedia?: ( mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, ) => void, +clickable: boolean, +setClickable: boolean => void, +onPress?: () => void, +onLongPress?: () => void, }; class InnerMultimediaMessage extends React.PureComponent { render(): React.Node { const { item } = this.props; const containerStyle = { height: item.contentHeight, width: item.contentWidth, }; return ( {this.renderContent()} ); } renderContent(): React.Node { const { messageInfo } = this.props.item; invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { return this.renderImage(messageInfo.media[0], 0, allCorners); } const mediaPerRow = getMediaPerRow(messageInfo.media.length); const rows = []; for (let i = 0; i < messageInfo.media.length; i += mediaPerRow) { const rowMedia = messageInfo.media.slice(i, i + mediaPerRow); const firstRow = i === 0; const lastRow = i + mediaPerRow >= messageInfo.media.length; const row = []; let j = 0; for (; j < rowMedia.length; j++) { const media = rowMedia[j]; const firstInRow = j === 0; const lastInRow = j + 1 === rowMedia.length; const inLastColumn = j + 1 === mediaPerRow; const corners = { topLeft: firstRow && firstInRow, topRight: firstRow && inLastColumn, bottomLeft: lastRow && firstInRow, bottomRight: lastRow && inLastColumn, }; const style = lastInRow ? null : styles.imageBeforeImage; row.push(this.renderImage(media, i + j, corners, style)); } for (; j < mediaPerRow; j++) { const key = `filler${j}`; const style = j + 1 < mediaPerRow ? [styles.filler, styles.imageBeforeImage] : styles.filler; row.push(); } const rowStyle = lastRow ? styles.row : [styles.row, styles.rowAboveRow]; rows.push( {row} , ); } return {rows}; } renderImage( media: Media, index: number, corners: Corners, style?: ViewStyle, ): React.Node { const filteredCorners = filterCorners(corners, this.props.item); const roundedStyle = getRoundedContainerStyle( filteredCorners, borderRadius, ); const { pendingUploads } = this.props.item; const mediaInfo = { ...media, - corners: filteredCorners, index, }; const pendingUpload = pendingUploads && pendingUploads[media.id]; return ( ); } } const styles = StyleSheet.create({ filler: { flex: 1, }, grid: { flex: 1, justifyContent: 'space-between', }, imageBeforeImage: { marginRight: spaceBetweenImages, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, rowAboveRow: { marginBottom: spaceBetweenImages, }, }); export { InnerMultimediaMessage, borderRadius as multimediaMessageBorderRadius, }; diff --git a/native/chat/settings/thread-settings-media-gallery.react.js b/native/chat/settings/thread-settings-media-gallery.react.js index 6a90a2b22..44d5d70ab 100644 --- a/native/chat/settings/thread-settings-media-gallery.react.js +++ b/native/chat/settings/thread-settings-media-gallery.react.js @@ -1,99 +1,127 @@ // @flow import * as React from 'react'; -import { - View, - TouchableOpacity, - Image, - useWindowDimensions, -} from 'react-native'; +import { View, useWindowDimensions } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; +import { fetchThreadMedia } from 'lib/actions/thread-actions.js'; +import type { MediaInfo } from 'lib/types/media-types'; +import { useServerCall } from 'lib/utils/action-utils.js'; + +import GestureTouchableOpacity from '../../components/gesture-touchable-opacity.react.js'; +import Multimedia from '../../media/multimedia.react.js'; import { useStyles } from '../../themes/colors.js'; const galleryItemGap = 8; const numColumns = 3; -function ThreadSettingsMediaGallery(): React.Node { +type ThreadSettingsMediaGalleryProps = { + +threadID: string, + +limit: number, +}; + +function ThreadSettingsMediaGallery( + props: ThreadSettingsMediaGalleryProps, +): React.Node { const styles = useStyles(unboundStyles); const { width } = useWindowDimensions(); // Explanation of galleryItemWidth: // The FlatList has a horizontal padding of 16px on each side, // and so the width of the actual FlatList is `width - 32px`. // With three columns, there will be two gaps in between the items, // so the width of each item (with the gaps) will be // (width - 32px - (numColumns-1) * galleryItemGap) / numColumns. // E.g. 16px, media, galleryItemGap, media, galleryItemGap, media, 16px const galleryItemWidth = (width - 32 - (numColumns - 1) * galleryItemGap) / numColumns; - const mediaInfos = React.useMemo(() => [], []); + + const { threadID, limit } = props; + const [mediaInfos, setMediaInfos] = React.useState([]); + const callFetchThreadMedia = useServerCall(fetchThreadMedia); + + React.useEffect(() => { + const fetchData = async () => { + const result = await callFetchThreadMedia({ + threadID, + limit, + offset: 0, + }); + setMediaInfos(result.media); + }; + fetchData(); + }, [callFetchThreadMedia, threadID, limit]); const memoizedStyles = React.useMemo(() => { return { mediaContainer: { marginTop: galleryItemGap, width: galleryItemWidth, ...styles.mediaContainer, }, mediaContainerWithMargin: { marginTop: galleryItemGap, marginLeft: galleryItemGap, width: galleryItemWidth, ...styles.mediaContainer, }, media: { width: galleryItemWidth, ...styles.media, }, }; }, [galleryItemWidth, styles.media, styles.mediaContainer]); const renderItem = React.useCallback( ({ item, index }) => { const containerStyle = index % numColumns === 0 ? memoizedStyles.mediaContainer : memoizedStyles.mediaContainerWithMargin; + const mediaInfoItem: MediaInfo = { + ...item, + index, + }; + return ( - - - + + + ); }, [ memoizedStyles.media, memoizedStyles.mediaContainer, memoizedStyles.mediaContainerWithMargin, ], ); return ( ); } const unboundStyles = { flatListContainer: { paddingHorizontal: 16, }, mediaContainer: { height: 180, justifyContent: 'center', alignItems: 'center', }, media: { height: 180, }, }; export default ThreadSettingsMediaGallery; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 4f59b2cbe..1e98c59bb 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1174 +1,1182 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Platform } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors.js'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, threadIsChannel, } from 'lib/shared/thread-utils.js'; import threadWatcher from 'lib/shared/thread-watcher.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { type ThreadInfo, type ResolvedThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { useResolvedThreadInfo, useResolvedOptionalThreadInfo, useResolvedOptionalThreadInfos, } from 'lib/utils/entity-helpers.js'; import type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react.js'; import ThreadSettingsChildThread from './thread-settings-child-thread.react.js'; import ThreadSettingsColor from './thread-settings-color.react.js'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react.js'; import ThreadSettingsDescription from './thread-settings-description.react.js'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react.js'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react.js'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react.js'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, } from './thread-settings-list-action.react.js'; import ThreadSettingsMediaGallery from './thread-settings-media-gallery.react.js'; import ThreadSettingsMember from './thread-settings-member.react.js'; import ThreadSettingsName from './thread-settings-name.react.js'; import ThreadSettingsParent from './thread-settings-parent.react.js'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react.js'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react.js'; import ThreadSettingsVisibility from './thread-settings-visibility.react.js'; import ThreadAncestors from '../../components/thread-ancestors.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, } from '../../navigation/route-names.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import type { TabNavigationProp } from '../../navigation/tab-navigator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import type { AppState } from '../../redux/state-types.js'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import type { ViewStyle } from '../../types/styles.js'; import type { ChatNavigationProp } from '../chat.react.js'; const itemPageLength = 5; export type ThreadSettingsParams = { +threadInfo: ThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'name', +key: string, +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ResolvedThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ResolvedThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ResolvedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ResolvedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'mediaGallery', +key: string, +threadInfo: ThreadInfo, + +limit: number, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: props.threadInfo.color, verticalBounds: null, }; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const prevThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if ( !tinycolor.equals(newThreadInfo.color, prevThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, prevThreadInfo.color) ) { this.setState({ colorEditValue: newThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); } listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( threadInfo: ResolvedThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; const canCreateSubchannels = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Threads', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ResolvedThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); mediaGalleryListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (threadInfo: ThreadInfo) => { const listData: ChatSettingsItem[] = []; + const limit = 6; listData.push({ itemType: 'header', key: 'mediaGalleryHeader', title: 'Media Gallery', categoryType: 'outline', }); listData.push({ itemType: 'mediaGallery', key: 'mediaGallery', threadInfo, + limit, }); listData.push({ itemType: 'footer', key: 'mediaGalleryFooter', categoryType: 'outline', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons(otherUserInfo); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.mediaGalleryListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], mediaGalleryListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...mediaGalleryListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem, ... }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'mediaGallery') { - return ; + return ( + + ); } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo(function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { invariant( reduxThreadInfo, 'ReduxThreadInfo should exist when ThreadSettings is opened', ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo: ThreadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); React.useEffect(() => { if (threadInChatList(threadInfo)) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [threadInfo]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const resolvedParentThreadInfo = useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const boundSomethingIsSaving = useSelector(state => somethingIsSaving(state, threadMembers), ); const { navigation } = props; React.useEffect(() => { const tabNavigation: ?TabNavigationProp<'Chat'> = navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !boundSomethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, boundSomethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); }); export default ConnectedThreadSettings;