From 7e6248dfb3779292b77489a2e90d4b95410d62cd Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 21 Dec 2021 17:44:00 +0200 Subject: [PATCH] [Gekidou] - Channel Intro (#5846) * Channel Intro * Move avatar margins to post component per feedback review * Channel intro redesign * Fix preferences unit test * Change group intro sizes * Add Bot tag to DM Intro if they have it * fix channel isTablet layout on split screen * update snapshot --- app/actions/local/channel.ts | 2 +- app/actions/remote/channel.ts | 69 +++- app/actions/remote/preference.ts | 41 +- app/actions/remote/systems.ts | 2 +- app/actions/remote/user.ts | 49 ++- app/actions/websocket/index.ts | 8 +- .../__snapshots__/index.test.tsx.snap | 4 +- app/components/channel_list/index.tsx | 5 +- app/components/post_list/index.tsx | 206 +--------- app/components/post_list/post/post.tsx | 29 +- .../system_message_helpers.test.js.snap | 60 ++- .../post/system_message/system_message.tsx | 4 +- app/components/post_list/post_list.tsx | 233 ++++++++++++ app/components/tag/index.tsx | 11 +- app/constants/screens.ts | 8 + .../handlers/user.test.ts | 5 +- .../server_data_operator/handlers/user.ts | 38 +- app/i18n/index.ts | 4 +- app/queries/servers/channel.ts | 9 + app/queries/servers/entry.ts | 2 +- app/queries/servers/preference.ts | 3 +- app/screens/channel/index.tsx | 112 ++---- .../intro/direct_channel/direct_channel.tsx | 152 ++++++++ .../intro/direct_channel/group/group.tsx | 78 ++++ .../intro/direct_channel/group/index.ts | 21 + .../channel/intro/direct_channel/index.ts | 45 +++ .../intro/direct_channel/member/index.ts | 15 + .../intro/direct_channel/member/member.tsx | 74 ++++ .../channel/intro/illustration/private.tsx | 272 +++++++++++++ .../channel/intro/illustration/public.tsx | 358 ++++++++++++++++++ app/screens/channel/intro/index.ts | 43 +++ app/screens/channel/intro/intro.tsx | 86 +++++ .../intro/options/favorite/favorite.tsx | 38 ++ .../channel/intro/options/favorite/index.ts | 29 ++ app/screens/channel/intro/options/index.tsx | 86 +++++ app/screens/channel/intro/options/item.tsx | 79 ++++ .../intro/public_or_private_channel/index.ts | 51 +++ .../public_or_private_channel.tsx | 145 +++++++ .../channel/intro/townsquare/index.tsx | 66 ++++ assets/base/i18n/en.json | 16 +- types/api/channels.d.ts | 5 + types/database/database.d.ts | 2 +- types/database/raw_values.d.ts | 1 + 43 files changed, 2202 insertions(+), 364 deletions(-) create mode 100644 app/components/post_list/post_list.tsx create mode 100644 app/screens/channel/intro/direct_channel/direct_channel.tsx create mode 100644 app/screens/channel/intro/direct_channel/group/group.tsx create mode 100644 app/screens/channel/intro/direct_channel/group/index.ts create mode 100644 app/screens/channel/intro/direct_channel/index.ts create mode 100644 app/screens/channel/intro/direct_channel/member/index.ts create mode 100644 app/screens/channel/intro/direct_channel/member/member.tsx create mode 100644 app/screens/channel/intro/illustration/private.tsx create mode 100644 app/screens/channel/intro/illustration/public.tsx create mode 100644 app/screens/channel/intro/index.ts create mode 100644 app/screens/channel/intro/intro.tsx create mode 100644 app/screens/channel/intro/options/favorite/favorite.tsx create mode 100644 app/screens/channel/intro/options/favorite/index.ts create mode 100644 app/screens/channel/intro/options/index.tsx create mode 100644 app/screens/channel/intro/options/item.tsx create mode 100644 app/screens/channel/intro/public_or_private_channel/index.ts create mode 100644 app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx create mode 100644 app/screens/channel/intro/townsquare/index.tsx diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index bb5e07c6b6..403464445e 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -44,7 +44,7 @@ export const switchToChannel = async (serverUrl: string, channelId: string, team } if (system.currentChannelId !== channelId) { - const history = await addChannelToTeamHistory(operator, system.currentTeamId, channelId, true); + const history = await addChannelToTeamHistory(operator, channel.teamId || system.currentTeamId, channelId, true); models.push(...history); } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 8fe5ba0b30..556988d14c 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -9,8 +9,8 @@ import {General} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import NetworkManager from '@init/network_manager'; -import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel'; -import {queryCommonSystemValues} from '@queries/servers/system'; +import {prepareMyChannelsForTeam, queryChannelById, queryMyChannel} from '@queries/servers/channel'; +import {queryCommonSystemValues, queryCurrentUserId} from '@queries/servers/system'; import {prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team'; import MyChannelModel from '@typings/database/models/servers/my_channel'; import MyTeamModel from '@typings/database/models/servers/my_team'; @@ -47,13 +47,23 @@ export const addMembersToChannel = async (serverUrl: string, channelId: string, try { const promises = userIds.map((id) => client.addToChannel(id, channelId, postRootId)); const channelMemberships: ChannelMembership[] = await Promise.all(promises); - await fetchUsersByIds(serverUrl, userIds, false); + const {users} = await fetchUsersByIds(serverUrl, userIds, true); if (!fetchOnly) { - await operator.handleChannelMembership({ + const modelPromises: Array> = []; + if (users) { + modelPromises.push(operator.handleUsers({ + users, + prepareRecordsOnly: true, + })); + } + modelPromises.push(operator.handleChannelMembership({ channelMemberships, - prepareRecordsOnly: false, - }); + prepareRecordsOnly: true, + })); + + const models = await Promise.all(modelPromises); + await operator.batchRecords(models.flat()); } return {channelMemberships}; } catch (error) { @@ -87,6 +97,53 @@ export const fetchChannelByName = async (serverUrl: string, teamId: string, chan } }; +export const fetchChannelCreator = async (serverUrl: string, channelId: string, fetchOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const currentUserId = await queryCurrentUserId(operator.database); + const channel = await queryChannelById(operator.database, channelId); + if (channel && channel.creatorId) { + const user = await client.getUser(channel.creatorId); + + if (!fetchOnly) { + const modelPromises: Array> = []; + if (user.id !== currentUserId) { + modelPromises.push(operator.handleUsers({ + users: [user], + prepareRecordsOnly: true, + })); + } + + modelPromises.push(operator.handleChannelMembership({ + channelMemberships: [{channel_id: channelId, user_id: channel.creatorId}], + prepareRecordsOnly: true, + })); + + const models = await Promise.all(modelPromises); + await operator.batchRecords(models.flat()); + } + + return {user}; + } + + return {user: undefined}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise => { let client: Client; try { diff --git a/app/actions/remote/preference.ts b/app/actions/remote/preference.ts index f86492843d..6b08e85cef 100644 --- a/app/actions/remote/preference.ts +++ b/app/actions/remote/preference.ts @@ -1,8 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Preferences} from '@constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; +import {queryCurrentUserId} from '@queries/servers/system'; import {forceLogoutIfNecessary} from './session'; @@ -25,9 +27,10 @@ export const fetchMyPreferences = async (serverUrl: string, fetchOnly = false): if (!fetchOnly) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (operator) { - operator.handlePreferences({ + await operator.handlePreferences({ prepareRecordsOnly: false, preferences, + sync: true, }); } } @@ -38,3 +41,39 @@ export const fetchMyPreferences = async (serverUrl: string, fetchOnly = false): return {error}; } }; + +export const saveFavoriteChannel = async (serverUrl: string, channelId: string, isFavorite: boolean) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + // Todo: @shaz I think you'll need to add the category handler here so that the channel is added/removed from the favorites category + const userId = await queryCurrentUserId(operator.database); + const favPref: PreferenceType = { + category: Preferences.CATEGORY_FAVORITE_CHANNEL, + name: channelId, + user_id: userId, + value: String(isFavorite), + }; + const preferences = [favPref]; + client.savePreferences(userId, preferences); + await operator.handlePreferences({ + preferences, + prepareRecordsOnly: false, + }); + + return {preferences}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; diff --git a/app/actions/remote/systems.ts b/app/actions/remote/systems.ts index 54f8e85e3e..77057da433 100644 --- a/app/actions/remote/systems.ts +++ b/app/actions/remote/systems.ts @@ -90,7 +90,7 @@ export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false } if (systems.length) { - operator.handleSystem({systems, prepareRecordsOnly: false}). + await operator.handleSystem({systems, prepareRecordsOnly: false}). catch((error) => { // eslint-disable-next-line no-console console.log('An error ocurred while saving config & license', error); diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index f0cd9af757..3b40d6cae0 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -48,7 +48,7 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false): Promise u.id !== excludeUserId); if (!fetchOnly) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (operator) { - const prepare = prepareUsers(operator, uniqueUsers.filter((u) => u.id !== excludeUserId)); + if (operator && filteredUsers.length) { + const modelPromises: Array> = []; + const membership = filteredUsers.map((u) => ({ + channel_id: channelId, + user_id: u.id, + })); + modelPromises.push(operator.handleChannelMembership({ + channelMemberships: membership, + prepareRecordsOnly: true, + })); + const prepare = prepareUsers(operator, filteredUsers); if (prepare) { - const models = await prepare; - if (models.length) { - await operator.batchRecords(models); - } + modelPromises.push(prepare); + } + + if (modelPromises.length) { + const models = await Promise.all(modelPromises); + await operator.batchRecords(models.flat()); } } } - return {channelId, users: uniqueUsers}; + return {channelId, users: filteredUsers}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientError); return {channelId, error}; @@ -98,18 +110,29 @@ export const fetchProfilesPerChannels = async (serverUrl: string, channelIds: st if (!fetchOnly) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (operator) { + const modelPromises: Array> = []; const users = new Set(); + const memberships: Array<{channel_id: string; user_id: string}> = []; for (const item of data) { if (item.users?.length) { - item.users.forEach(users.add, users); + item.users.forEach((u) => { + users.add(u); + memberships.push({channel_id: item.channelId, user_id: u.id}); + }); } } + modelPromises.push(operator.handleChannelMembership({ + channelMemberships: memberships, + prepareRecordsOnly: true, + })); const prepare = prepareUsers(operator, Array.from(users).filter((u) => u.id !== excludeUserId)); if (prepare) { - const models = await prepare; - if (models.length) { - await operator.batchRecords(models); - } + modelPromises.push(prepare); + } + + if (modelPromises.length) { + const models = await Promise.all(modelPromises); + await operator.batchRecords(models.flat()); } } } diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index f27770baf1..5a75cc0195 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -73,8 +73,8 @@ async function doReconnect(serverUrl: string) { const lastDisconnectedAt = await queryWebSocketLastDisconnected(database.database); // TODO consider fetch only and batch all the results. - fetchMe(serverUrl); - fetchMyPreferences(serverUrl); + await fetchMe(serverUrl); + await fetchMyPreferences(serverUrl); const {config} = await fetchConfigAndLicense(serverUrl); const {memberships: teamMemberships, error: teamMembershipsError} = await fetchMyTeams(serverUrl); const {currentChannelId, currentUserId, currentTeamId, license} = system; @@ -82,7 +82,7 @@ async function doReconnect(serverUrl: string) { let channelMemberships: ChannelMembership[] | undefined; if (currentTeamMembership) { - const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, true, lastDisconnectedAt, true); + const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, true, lastDisconnectedAt); if (error) { DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error); return; @@ -92,7 +92,7 @@ async function doReconnect(serverUrl: string) { const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, license); const directChannels = channels?.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL); if (directChannels?.length) { - await fetchMissingSidebarInfo(serverUrl, directChannels, currentUser?.locale, teammateDisplayNameSetting, currentUserId, true); + await fetchMissingSidebarInfo(serverUrl, directChannels, currentUser?.locale, teammateDisplayNameSetting, currentUserId); } const modelPromises: Array> = []; diff --git a/app/components/channel_list/__snapshots__/index.test.tsx.snap b/app/components/channel_list/__snapshots__/index.test.tsx.snap index 494c956d30..d8ad7f5bfc 100644 --- a/app/components/channel_list/__snapshots__/index.test.tsx.snap +++ b/app/components/channel_list/__snapshots__/index.test.tsx.snap @@ -5,7 +5,7 @@ exports[`components/channel_list should match snapshot 1`] = ` animatedStyle={ Object { "value": Object { - "maxWidth": undefined, + "maxWidth": "100%", }, } } @@ -14,7 +14,7 @@ exports[`components/channel_list should match snapshot 1`] = ` Object { "backgroundColor": "#1e325c", "flex": 1, - "maxWidth": undefined, + "maxWidth": "100%", "paddingHorizontal": 20, "paddingVertical": 10, } diff --git a/app/components/channel_list/index.tsx b/app/components/channel_list/index.tsx index 89d0708637..1afce0e963 100644 --- a/app/components/channel_list/index.tsx +++ b/app/components/channel_list/index.tsx @@ -50,12 +50,12 @@ const ChannelList = ({iconPad, isTablet, teamsCount}: ChannelListProps) => { const tabletStyle = useAnimatedStyle(() => { if (!isTablet) { return { - maxWidth: undefined, + maxWidth: '100%', }; } return {maxWidth: withTiming(tabletWidth.value, {duration: 350})}; - }, []); + }, [isTablet]); useEffect(() => { if (isTablet) { @@ -64,7 +64,6 @@ const ChannelList = ({iconPad, isTablet, teamsCount}: ChannelListProps) => { }, [isTablet, teamsCount]); const [showCats, setShowCats] = useState(true); - return ( setShowCats(!showCats)}> diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index 6b531ed0d8..a5f50cedf2 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -4,23 +4,17 @@ import {Q} from '@nozbe/watermelondb'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import React, {ReactElement, useCallback} from 'react'; -import {AppStateStatus, DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native'; +import {AppStateStatus} from 'react-native'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import CombinedUserActivity from '@components/post_list/combined_user_activity'; -import DateSeparator from '@components/post_list/date_separator'; -import NewMessagesLine from '@components/post_list/new_message_line'; -import Post from '@components/post_list/post'; import {Preferences} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; -import {useTheme} from '@context/theme'; import {getPreferenceAsBool} from '@helpers/api/preference'; -import {emptyFunction} from '@utils/general'; -import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list'; import {getTimezone} from '@utils/user'; +import PostList from './post_list'; + import type {WithDatabaseArgs} from '@typings/database/database'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type PostModel from '@typings/database/models/servers/post'; @@ -29,42 +23,6 @@ import type PreferenceModel from '@typings/database/models/servers/preference'; import type SystemModel from '@typings/database/models/servers/system'; import type UserModel from '@typings/database/models/servers/user'; -type RefreshProps = { - children: ReactElement; - enabled: boolean; - onRefresh: () => void; - refreshing: boolean; -} - -type Props = { - currentTimezone: string | null; - currentUsername: string; - isTimezoneEnabled: boolean; - lastViewedAt: number; - posts: PostModel[]; - shouldShowJoinLeaveMessages: boolean; - testID: string; -} - -type ViewableItemsChanged = { - viewableItems: ViewToken[]; - changed: ViewToken[]; -} - -const style = StyleSheet.create({ - container: { - flex: 1, - scaleY: -1, - }, - scale: { - ...Platform.select({ - android: { - scaleY: -1, - }, - }), - }, -}); - const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES; export const VIEWABILITY_CONFIG = { @@ -72,161 +30,7 @@ export const VIEWABILITY_CONFIG = { minimumViewTime: 100, }; -const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => { - const props = { - onRefresh, - refreshing, - }; - - if (Platform.OS === 'android') { - return ( - - {children} - - ); - } - - const refreshControl = ; - - return React.cloneElement( - children, - {refreshControl, inverted: true}, - ); -}; - -const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => { - const theme = useTheme(); - const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false); - - const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { - if (!viewableItems.length) { - return; - } - - const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { - if (isViewable) { - acc[item.id] = true; - } - return acc; - }, {}); - - DeviceEventEmitter.emit('scrolled', viewableItemsMap); - }, []); - - const renderItem = useCallback(({item, index}) => { - if (typeof item === 'string') { - if (isStartOfNewMessages(item)) { - // postIds includes a date item after the new message indicator so 2 - // needs to be added to the index for the length check to be correct. - const moreNewMessages = orderedPosts.length === index + 2; - - // The date line and new message line each count for a line. So the - // goal of this is to check for the 3rd previous, which for the start - // of a thread would be null as it doesn't exist. - const checkForPostId = index < orderedPosts.length - 3; - - return ( - - ); - } else if (isDateLine(item)) { - return ( - - ); - } - - if (isCombinedUserActivityPost(item)) { - const postProps = { - currentUsername, - postId: item, - style: Platform.OS === 'ios' ? style.scale : style.container, - testID: `${testID}.combined_user_activity`, - showJoinLeave: shouldShowJoinLeaveMessages, - theme, - }; - - return (); - } - } - - let previousPost: PostModel|undefined; - let nextPost: PostModel|undefined; - if (index < posts.length - 1) { - const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string'); - if (prev) { - previousPost = prev as PostModel; - } - } - - if (index > 0) { - const next = orderedPosts.slice(0, index); - for (let i = next.length - 1; i >= 0; i--) { - const v = next[i]; - if (typeof v !== 'string') { - nextPost = v; - break; - } - } - } - - const postProps = { - highlightPinnedOrFlagged: true, - location: 'Channel', - nextPost, - previousPost, - shouldRenderReplyButton: true, - }; - - return ( - - ); - }, [orderedPosts, theme]); - - return ( - - (typeof item === 'string' ? item : item.id)} - style={{flex: 1}} - contentContainerStyle={{paddingTop: 5}} - initialNumToRender={10} - maxToRenderPerBatch={10} - removeClippedSubviews={true} - onViewableItemsChanged={onViewableItemsChanged} - viewabilityConfig={VIEWABILITY_CONFIG} - scrollEventThrottle={60} - /> - - ); -}; - -const withPosts = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => { +const enhanced = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => { const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( switchMap((currentUserId) => database.get(USER).findAndObserve(currentUserId.value)), ); @@ -269,4 +73,4 @@ const withPosts = withObservables(['channelId', 'forceQueryAfterAppState'], ({da }; }); -export default withDatabase(withPosts(PostList)); +export default withDatabase(enhanced(PostList)); diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index b7cdedd14f..b8ef7c613f 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -61,7 +61,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { consecutivePostContainer: { marginBottom: 10, marginRight: 10, - marginLeft: 47, + marginLeft: 27, marginTop: 10, }, container: {flexDirection: 'row'}, @@ -76,6 +76,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { overflow: 'hidden', flex: 1, }, + profilePictureContainer: { + marginBottom: 5, + marginRight: 10, + marginTop: 10, + }, replyBar: { backgroundColor: theme.centerChannelColor, opacity: 0.1, @@ -89,7 +94,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { rightColumn: { flex: 1, flexDirection: 'column', - marginRight: 12, }, rightColumnPadding: {paddingBottom: 3}, }; @@ -186,15 +190,18 @@ const Post = ({ consecutiveStyle = styles.consective; postAvatar = ; } else { - postAvatar = isAutoResponder ? ( - - ) : ( - + postAvatar = ( + + {isAutoResponder ? ( + + ) : ( + + )} + ); if (isSystemPost && !isAutoResponder) { diff --git a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap index e600f34494..f7c4d4e13a 100644 --- a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap +++ b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap @@ -18,8 +18,10 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -37,8 +39,10 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" @@ -67,8 +71,10 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = ` Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -86,8 +92,10 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = ` style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" @@ -103,8 +111,10 @@ exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = ` style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } > @@ -130,8 +140,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1 Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -149,8 +161,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1 style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" @@ -179,8 +193,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2 Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -198,8 +214,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2 style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" @@ -211,8 +229,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2 Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -247,8 +267,10 @@ exports[`renderSystemMessage uses renderer for OLD archived channel without a us style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" @@ -277,8 +299,10 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = ` Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -296,8 +320,10 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = ` style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" @@ -326,8 +352,10 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = ` Array [ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, }, Object { "opacity": 1, @@ -345,8 +373,10 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = ` style={ Object { "color": "rgba(63,67,80,0.6)", + "fontFamily": "OpenSans", "fontSize": 16, - "lineHeight": 20, + "fontWeight": "400", + "lineHeight": 24, } } testID="markdown_text" diff --git a/app/components/post_list/post/system_message/system_message.tsx b/app/components/post_list/post/system_message/system_message.tsx index 1f029219cb..6ce76c56e7 100644 --- a/app/components/post_list/post/system_message/system_message.tsx +++ b/app/components/post_list/post/system_message/system_message.tsx @@ -11,6 +11,7 @@ import {useTheme} from '@context/theme'; import {t} from '@i18n'; import {getMarkdownTextStyles} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; @@ -46,8 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { systemMessage: { color: changeOpacity(theme.centerChannelColor, 0.6), - fontSize: 16, - lineHeight: 20, + ...typography('Body', 200, 'Regular'), }, }; }); diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx new file mode 100644 index 0000000000..dedafc0ba0 --- /dev/null +++ b/app/components/post_list/post_list.tsx @@ -0,0 +1,233 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ReactElement, useCallback, useEffect, useRef} from 'react'; +import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native'; + +import CombinedUserActivity from '@components/post_list/combined_user_activity'; +import DateSeparator from '@components/post_list/date_separator'; +import NewMessagesLine from '@components/post_list/new_message_line'; +import Post from '@components/post_list/post'; +import {useTheme} from '@context/theme'; +import {emptyFunction} from '@utils/general'; +import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list'; + +import type PostModel from '@typings/database/models/servers/post'; + +type RefreshProps = { + children: ReactElement; + enabled: boolean; + onRefresh: () => void; + refreshing: boolean; +} + +type Props = { + channelId: string; + contentContainerStyle?: StyleProp; + currentTimezone: string | null; + currentUsername: string; + isTimezoneEnabled: boolean; + lastViewedAt: number; + posts: PostModel[]; + shouldShowJoinLeaveMessages: boolean; + footer?: ReactElement; + testID: string; +} + +type ViewableItemsChanged = { + viewableItems: ViewToken[]; + changed: ViewToken[]; +} + +const style = StyleSheet.create({ + container: { + flex: 1, + scaleY: -1, + }, + scale: { + ...Platform.select({ + android: { + scaleY: -1, + }, + }), + }, +}); + +export const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 1, + minimumViewTime: 100, +}; + +const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id); + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + content: { + marginHorizontal: 20, + }, +}); + +const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => { + const props = { + onRefresh, + refreshing, + }; + + if (Platform.OS === 'android') { + return ( + + {children} + + ); + } + + const refreshControl = ; + + return React.cloneElement( + children, + {refreshControl, inverted: true}, + ); +}; + +const PostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, footer, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => { + const listRef = useRef(null); + const theme = useTheme(); + const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false); + + useEffect(() => { + listRef.current?.scrollToOffset({offset: 0, animated: false}); + }, [channelId, listRef.current]); + + const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { + if (!viewableItems.length) { + return; + } + + const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { + if (isViewable) { + acc[item.id] = true; + } + return acc; + }, {}); + + DeviceEventEmitter.emit('scrolled', viewableItemsMap); + }, []); + + const renderItem = useCallback(({item, index}) => { + if (typeof item === 'string') { + if (isStartOfNewMessages(item)) { + // postIds includes a date item after the new message indicator so 2 + // needs to be added to the index for the length check to be correct. + const moreNewMessages = orderedPosts.length === index + 2; + + // The date line and new message line each count for a line. So the + // goal of this is to check for the 3rd previous, which for the start + // of a thread would be null as it doesn't exist. + const checkForPostId = index < orderedPosts.length - 3; + + return ( + + ); + } else if (isDateLine(item)) { + return ( + + ); + } + + if (isCombinedUserActivityPost(item)) { + const postProps = { + currentUsername, + postId: item, + style: Platform.OS === 'ios' ? style.scale : style.container, + testID: `${testID}.combined_user_activity`, + showJoinLeave: shouldShowJoinLeaveMessages, + theme, + }; + + return (); + } + } + + let previousPost: PostModel|undefined; + let nextPost: PostModel|undefined; + if (index < posts.length - 1) { + const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string'); + if (prev) { + previousPost = prev as PostModel; + } + } + + if (index > 0) { + const next = orderedPosts.slice(0, index); + for (let i = next.length - 1; i >= 0; i--) { + const v = next[i]; + if (typeof v !== 'string') { + nextPost = v; + break; + } + } + } + + const postProps = { + highlightPinnedOrFlagged: true, + location: 'Channel', + nextPost, + previousPost, + shouldRenderReplyButton: true, + }; + + return ( + + ); + }, [orderedPosts, theme]); + + return ( + + + + ); +}; + +export default PostList; diff --git a/app/components/tag/index.tsx b/app/components/tag/index.tsx index 19343e91ae..274b9851dd 100644 --- a/app/components/tag/index.tsx +++ b/app/components/tag/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; -import {StyleProp, View, ViewStyle} from 'react-native'; +import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; @@ -16,14 +16,15 @@ type TagProps = { show?: boolean; style?: StyleProp; testID?: string; + textStyle?: StyleProp; } const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { container: { alignSelf: 'center', - backgroundColor: changeOpacity(theme.centerChannelColor, 0.15), - borderRadius: 2, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: 4, marginRight: 2, marginBottom: 1, marginLeft: 2, @@ -68,7 +69,7 @@ export function GuestTag(props: Omit) { ); } -const Tag = ({id, defaultMessage, inTitle, show = true, style, testID}: TagProps) => { +const Tag = ({id, defaultMessage, inTitle, show = true, style, testID, textStyle}: TagProps) => { const theme = useTheme(); if (!show) { @@ -82,7 +83,7 @@ const Tag = ({id, defaultMessage, inTitle, show = true, style, testID}: TagProps diff --git a/app/constants/screens.ts b/app/constants/screens.ts index ee6adcc75c..92d9ec5d7e 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -6,6 +6,9 @@ export const EMOJI_PICKER = 'AddReaction'; export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const CHANNEL = 'Channel'; +export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople'; +export const CHANNEL_DETAILS = 'ChannelDetails'; +export const CHANNEL_EDIT = 'ChannelEdit'; export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; export const CUSTOM_STATUS = 'CustomStatus'; export const FORGOT_PASSWORD = 'ForgotPassword'; @@ -21,6 +24,7 @@ export const SERVER = 'Server'; export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const SSO = 'SSO'; export const THREAD = 'Thread'; +export const USER_PROFILE = 'UserProfile'; export const MENTIONS = 'Mentions'; export default { @@ -29,6 +33,9 @@ export default { APP_FORM, BOTTOM_SHEET, CHANNEL, + CHANNEL_ADD_PEOPLE, + CHANNEL_EDIT, + CHANNEL_DETAILS, CUSTOM_STATUS_CLEAR_AFTER, CUSTOM_STATUS, FORGOT_PASSWORD, @@ -44,5 +51,6 @@ export default { SETTINGS_SIDEBAR, SSO, THREAD, + USER_PROFILE, MENTIONS, }; diff --git a/app/database/operator/server_data_operator/handlers/user.test.ts b/app/database/operator/server_data_operator/handlers/user.test.ts index 391f0fc223..a777890d6b 100644 --- a/app/database/operator/server_data_operator/handlers/user.test.ts +++ b/app/database/operator/server_data_operator/handlers/user.test.ts @@ -151,9 +151,8 @@ describe('*** Operator: User Handlers tests ***', () => { expect(spyOnHandleRecords).toHaveBeenCalledWith({ fieldName: 'user_id', createOrUpdateRawValues: preferences, - deleteRawValues: [], tableName: 'Preference', - prepareRecordsOnly: false, + prepareRecordsOnly: true, findMatchingRecordBy: isRecordPreferenceEqualToRaw, transformer: transformPreferenceRecord, }); @@ -163,6 +162,7 @@ describe('*** Operator: User Handlers tests ***', () => { expect.assertions(2); const channelMemberships: ChannelMembership[] = [ { + id: '17bfnb1uwb8epewp4q3x3rx9go-9ciscaqbrpd6d8s68k76xb9bte', channel_id: '17bfnb1uwb8epewp4q3x3rx9go', user_id: '9ciscaqbrpd6d8s68k76xb9bte', roles: 'wqyby5r5pinxxdqhoaomtacdhc', @@ -181,6 +181,7 @@ describe('*** Operator: User Handlers tests ***', () => { scheme_admin: false, }, { + id: '1yw6gxfr4bn1jbyp9nr7d53yew-9ciscaqbrpd6d8s68k76xb9bte', channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew', user_id: '9ciscaqbrpd6d8s68k76xb9bte', roles: 'channel_user', diff --git a/app/database/operator/server_data_operator/handlers/user.ts b/app/database/operator/server_data_operator/handlers/user.ts index 58a4278ee4..26f51fd880 100644 --- a/app/database/operator/server_data_operator/handlers/user.ts +++ b/app/database/operator/server_data_operator/handlers/user.ts @@ -59,7 +59,12 @@ const UserHandler = (superclass: any) => class extends superclass { ); } - const createOrUpdateRawValues = getUniqueRawsBy({raws: channelMemberships, key: 'channel_id'}); + const memberships: ChannelMember[] = channelMemberships.map((m) => ({ + id: `${m.channel_id}-${m.user_id}`, + ...m, + })); + + const createOrUpdateRawValues = getUniqueRawsBy({raws: memberships, key: 'id'}); return this.handleRecords({ fieldName: 'user_id', @@ -87,33 +92,36 @@ const UserHandler = (superclass: any) => class extends superclass { } // WE NEED TO SYNC THE PREFS FROM WHAT WE GOT AND WHAT WE HAVE - const deleteRawValues: PreferenceType[] = []; + const deleteValues: PreferenceModel[] = []; if (sync) { - const stored = await this.database.get(PREFERENCE).fetch() as PreferenceModel[]; + const stored = await this.database.get(PREFERENCE).query().fetch() as PreferenceModel[]; for (const pref of stored) { const exists = preferences.findIndex((p) => p.category === pref.category && p.name === pref.name) > -1; if (!exists) { - deleteRawValues.push({ - category: pref.category, - name: pref.name, - user_id: pref.userId, - value: pref.value, - }); + pref.prepareDestroyPermanently(); + deleteValues.push(pref); } } } - const createOrUpdateRawValues = getUniqueRawsBy({raws: preferences, key: 'name'}); - - return this.handleRecords({ + const records: PreferenceModel[] = await this.handleRecords({ fieldName: 'user_id', findMatchingRecordBy: isRecordPreferenceEqualToRaw, transformer: transformPreferenceRecord, - prepareRecordsOnly, - createOrUpdateRawValues, - deleteRawValues, + prepareRecordsOnly: true, + createOrUpdateRawValues: preferences, tableName: PREFERENCE, }); + + if (deleteValues.length) { + records.push(...deleteValues); + } + + if (records.length && !prepareRecordsOnly) { + await this.batchRecords(records); + } + + return records; }; /** diff --git a/app/i18n/index.ts b/app/i18n/index.ts index 73c91e6b40..ab53571eee 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -13,7 +13,7 @@ export const DEFAULT_LOCALE = deviceLocale; function loadTranslation(locale?: string) { try { - let translations; + let translations: Record; let momentData; switch (locale) { @@ -184,7 +184,7 @@ export function getLocalizedMessage(lang: string, id: string, defaultMessage?: s const locale = getLocaleFromLanguage(lang); const translations = getTranslations(locale); - return translations[id] || defaultMessage; + return translations[id] || defaultMessage || ''; } export function t(v: string): string { diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index f89e4f0184..747bf9cec1 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -140,6 +140,15 @@ export const queryMyChannel = async (database: Database, channelId: string) => { } }; +export const queryChannelById = async (database: Database, channelId: string) => { + try { + const channel = await database.get(CHANNEL).find(channelId); + return channel; + } catch { + return undefined; + } +}; + export const queryChannelByName = async (database: Database, channelName: string) => { try { const channels = await database.get(CHANNEL).query(Q.where('name', channelName)).fetch() as ChannelModel[]; diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index 9168234b1b..e3fbb1dbc5 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -57,7 +57,7 @@ export const prepareModels = async ({operator, initialTeamId, removeTeams, remov } if (prefData?.preferences?.length) { - const prefModel = prepareMyPreferences(operator, prefData.preferences); + const prefModel = prepareMyPreferences(operator, prefData.preferences, true); if (prefModel) { modelPromises.push(prefModel); } diff --git a/app/queries/servers/preference.ts b/app/queries/servers/preference.ts index 23adefe668..f66a8ad76c 100644 --- a/app/queries/servers/preference.ts +++ b/app/queries/servers/preference.ts @@ -11,11 +11,12 @@ import {queryCurrentTeamId} from './system'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type PreferenceModel from '@typings/database/models/servers/preference'; -export const prepareMyPreferences = (operator: ServerDataOperator, preferences: PreferenceType[]) => { +export const prepareMyPreferences = (operator: ServerDataOperator, preferences: PreferenceType[], sync = false) => { try { return operator.handlePreferences({ prepareRecordsOnly: true, preferences, + sync, }); } catch { return undefined; diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 0a54639b60..5c69a333a4 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -3,25 +3,22 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import React, {useMemo} from 'react'; -import {useIntl} from 'react-intl'; -import {Text, View} from 'react-native'; +import React, {useEffect, useState} from 'react'; import {SafeAreaView} from 'react-native-safe-area-context'; import {map} from 'rxjs/operators'; -import {logout} from '@actions/remote/session'; +import {fetchPostsForChannel} from '@actions/remote/post'; import PostList from '@components/post_list'; -import ServerVersion from '@components/server_version'; -import {Screens, Database} from '@constants'; +import {Database} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useAppState} from '@hooks/device'; -import {goToScreen} from '@screens/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; import ChannelNavBar from './channel_nav_bar'; import FailedChannels from './failed_channels'; import FailedTeams from './failed_teams'; +import Intro from './intro'; import type {WithDatabaseArgs} from '@typings/database/database'; import type SystemModel from '@typings/database/models/servers/system'; @@ -30,7 +27,6 @@ import type {LaunchProps} from '@typings/launch'; type ChannelProps = LaunchProps & { currentChannelId: string; currentTeamId: string; - time?: number; }; const {MM_TABLES, SYSTEM_IDENTIFIERS} = Database; @@ -51,85 +47,49 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => { - // TODO: If we have LaunchProps, ensure we load the correct channel/post/modal. - // TODO: If LaunchProps.error is true, use the LaunchProps.launchType to determine which - // error message to display. For example: - // if (props.launchError) { - // let erroMessage; - // if (props.launchType === LaunchType.DeepLink) { - // errorMessage = intl.formatMessage({id: 'mobile.launchError.deepLink', defaultMessage: 'Did not find a server for this deep link'}); - // } else if (props.launchType === LaunchType.Notification) { - // errorMessage = intl.formatMessage({id: 'mobile.launchError.notification', defaultMessage: 'Did not find a server for this notification'}); - // } - // } - - //todo: https://mattermost.atlassian.net/browse/MM-37266 - - const theme = useTheme(); - const intl = useIntl(); - const styles = getStyleSheet(theme); +const Channel = ({currentChannelId, currentTeamId}: ChannelProps) => { const appState = useAppState(); - + const [loading, setLoading] = useState(false); const serverUrl = useServerUrl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); - const doLogout = () => { - logout(serverUrl!); - }; + useEffect(() => { + setLoading(true); + fetchPostsForChannel(serverUrl, currentChannelId).then(() => { + setLoading(false); + }); + }, [currentChannelId]); - const goToAbout = () => { - const title = intl.formatMessage({id: 'about.title', defaultMessage: 'About {appTitle}'}, {appTitle: 'Mattermost'}); - goToScreen(Screens.ABOUT, title); - }; + if (!currentTeamId) { + return ; + } - const renderComponent = useMemo(() => { - if (!currentTeamId) { - return ; - } - - if (!currentChannelId) { - return ; - } - - return ( - <> - null} - /> - - - - {`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`} - - - - - {'Go to About Screen'} - - - - ); - }, [currentTeamId, currentChannelId, theme, appState]); + if (!currentChannelId) { + return ; + } return ( - - {renderComponent} + null} + /> + + )} + forceQueryAfterAppState={appState} + testID='channel.post_list' + /> ); }; diff --git a/app/screens/channel/intro/direct_channel/direct_channel.tsx b/app/screens/channel/intro/direct_channel/direct_channel.tsx new file mode 100644 index 0000000000..98c9140adb --- /dev/null +++ b/app/screens/channel/intro/direct_channel/direct_channel.tsx @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo} from 'react'; +import {Text, View} from 'react-native'; + +import {fetchProfilesInChannel} from '@actions/remote/user'; +import FormattedText from '@components/formatted_text'; +import {BotTag} from '@components/tag'; +import {General} from '@constants'; +import {useServerUrl} from '@context/server'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import IntroOptions from '../options'; + +import Group from './group'; +import Member from './member'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; + +type Props = { + channel: ChannelModel; + currentUserId: string; + isBot: boolean; + members?: ChannelMembershipModel[]; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + botContainer: { + alignSelf: 'flex-end', + bottom: 7.5, + height: 20, + marginBottom: 0, + marginLeft: 4, + paddingVertical: 0, + }, + botText: { + fontSize: 14, + lineHeight: 20, + }, + container: { + alignItems: 'center', + }, + message: { + color: theme.centerChannelColor, + marginTop: 16, + textAlign: 'center', + ...typography('Body', 200, 'Regular'), + }, + profilesContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + title: { + color: theme.centerChannelColor, + marginTop: 16, + textAlign: 'center', + ...typography('Heading', 700, 'SemiBold'), + }, + titleGroup: { + ...typography('Heading', 600, 'SemiBold'), + }, +})); + +const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) => { + const serverUrl = useServerUrl(); + const styles = getStyleSheet(theme); + + useEffect(() => { + const channelMembers = members?.filter((m) => m.userId !== currentUserId); + if (!channelMembers?.length) { + fetchProfilesInChannel(serverUrl, channel.id, currentUserId, false); + } + }, []); + + const message = useMemo(() => { + if (channel.type === General.DM_CHANNEL) { + return ( + + ); + } + return ( + + ); + }, [channel.displayName, theme]); + + const profiles = useMemo(() => { + const channelMembers = members?.filter((m) => m.userId !== currentUserId); + if (!channelMembers?.length) { + return null; + } + + if (channel.type === General.DM_CHANNEL) { + return ( + + ); + } + + return ( + cm.userId)} + /> + ); + }, [members, theme]); + + return ( + + + {profiles} + + + + {channel.displayName} + + {isBot && + + } + + {message} + + + ); +}; + +export default DirectChannel; diff --git a/app/screens/channel/intro/direct_channel/group/group.tsx b/app/screens/channel/intro/direct_channel/group/group.tsx new file mode 100644 index 0000000000..cb37ac5638 --- /dev/null +++ b/app/screens/channel/intro/direct_channel/group/group.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {chunk} from 'lodash'; +import React from 'react'; +import {View} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import {useServerUrl} from '@context/server'; +import NetworkManager from '@init/network_manager'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import type {Client} from '@client/rest'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + theme: Theme; + users: UserModel[]; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: 12, + }, + profile: { + borderColor: theme.centerChannelBg, + borderRadius: 36, + borderWidth: 2, + height: 72, + width: 72, + }, +})); + +const Group = ({theme, users}: Props) => { + const serverUrl = useServerUrl(); + const styles = getStyleSheet(theme); + + let client: Client | undefined; + + try { + client = NetworkManager.getClient(serverUrl); + } catch { + return null; + } + + const rows = chunk(users, 5); + const groups = rows.map((c, k) => { + const group = c.map((u, i) => { + const pictureUrl = client!.getProfilePictureUrl(u.id, u.lastPictureUpdate); + return ( + + ); + }); + + return ( + + {group} + + ); + }); + + return ( + <> + {groups} + + ); +}; + +export default Group; diff --git a/app/screens/channel/intro/direct_channel/group/index.ts b/app/screens/channel/intro/direct_channel/group/index.ts new file mode 100644 index 0000000000..920750bfde --- /dev/null +++ b/app/screens/channel/intro/direct_channel/group/index.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import {MM_TABLES} from '@constants/database'; + +import Group from './group'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {USER}} = MM_TABLES; + +const enhanced = withObservables([], ({userIds, database}: {userIds: string[]} & WithDatabaseArgs) => ({ + users: database.get(USER).query(Q.where('id', Q.oneOf(userIds))).observeWithColumns(['last_picture_update']), +})); + +export default withDatabase(enhanced(Group)); diff --git a/app/screens/channel/intro/direct_channel/index.ts b/app/screens/channel/intro/direct_channel/index.ts new file mode 100644 index 0000000000..f24a666608 --- /dev/null +++ b/app/screens/channel/intro/direct_channel/index.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {catchError, switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database'; +import {General} from '@constants'; +import {getUserIdFromChannelName} from '@utils/user'; + +import DirectChannel from './direct_channel'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const enhanced = withObservables([], ({channel, database}: {channel: ChannelModel} & WithDatabaseArgs) => { + const currentUserId = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(switchMap(({value}) => of$(value))); + const members = channel.members.observe(); + let isBot = of$(false); + + if (channel.type === General.DM_CHANNEL) { + isBot = currentUserId.pipe( + switchMap((userId: string) => { + const otherUserId = getUserIdFromChannelName(userId, channel.name); + return database.get(MM_TABLES.SERVER.USER).findAndObserve(otherUserId).pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((user) => of$(user.isBot)), // eslint-disable-next-line max-nested-callbacks + catchError(() => of$(false)), + ); + }), + ); + } + + return { + currentUserId, + isBot, + members, + }; +}); + +export default withDatabase(enhanced(DirectChannel)); diff --git a/app/screens/channel/intro/direct_channel/member/index.ts b/app/screens/channel/intro/direct_channel/member/index.ts new file mode 100644 index 0000000000..2e1577dcb8 --- /dev/null +++ b/app/screens/channel/intro/direct_channel/member/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import Member from './member'; + +import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; + +const enhanced = withObservables([], ({member}: {member: ChannelMembershipModel}) => ({ + user: member.memberUser.observe(), +})); + +export default withDatabase(enhanced(Member)); diff --git a/app/screens/channel/intro/direct_channel/member/member.tsx b/app/screens/channel/intro/direct_channel/member/member.tsx new file mode 100644 index 0000000000..24b1d5515a --- /dev/null +++ b/app/screens/channel/intro/direct_channel/member/member.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleProp, StyleSheet, ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import ProfilePicture from '@components/profile_picture'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Screens} from '@constants'; +import {showModal} from '@screens/navigation'; + +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + containerStyle?: StyleProp; + size?: number; + showStatus?: boolean; + theme: Theme; + user: UserModel; +} + +const styles = StyleSheet.create({ + profile: { + height: 67, + marginBottom: 12, + marginRight: 12, + }, +}); + +const Member = ({containerStyle, size = 72, showStatus = true, theme, user}: Props) => { + const intl = useIntl(); + const onPress = useCallback(() => { + const screen = Screens.USER_PROFILE; + const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); + const passProps = { + userId: user.id, + }; + + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + + const options = { + topBar: { + leftButtons: [{ + id: 'close-user-profile', + icon: closeButton, + testID: 'close.settings.button', + }], + }, + }; + + showModal(screen, title, passProps, options); + }, [theme]); + + return ( + + + + ); +}; + +export default Member; diff --git a/app/screens/channel/intro/illustration/private.tsx b/app/screens/channel/intro/illustration/private.tsx new file mode 100644 index 0000000000..00b0e6401f --- /dev/null +++ b/app/screens/channel/intro/illustration/private.tsx @@ -0,0 +1,272 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as React from 'react'; +import Svg, { + G, + Path, + Ellipse, + Mask, + Defs, + Pattern, + Use, + Image, + LinearGradient, + Stop, + ClipPath, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +}; + +const PrivateChannelIllustration = ({theme}: Props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default PrivateChannelIllustration; diff --git a/app/screens/channel/intro/illustration/public.tsx b/app/screens/channel/intro/illustration/public.tsx new file mode 100644 index 0000000000..7d9265e2aa --- /dev/null +++ b/app/screens/channel/intro/illustration/public.tsx @@ -0,0 +1,358 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as React from 'react'; +import Svg, { + G, + Path, + Mask, + Ellipse, + Defs, + Pattern, + Use, + Image, + LinearGradient, + Stop, + ClipPath, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +}; + +const PublicChannelIllustration = ({theme}: Props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default PublicChannelIllustration; diff --git a/app/screens/channel/intro/index.ts b/app/screens/channel/intro/index.ts new file mode 100644 index 0000000000..02623184a7 --- /dev/null +++ b/app/screens/channel/intro/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database'; + +import Intro from './intro'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type RoleModel from '@typings/database/models/servers/role'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {CHANNEL, MY_CHANNEL, ROLE, SYSTEM, USER}} = MM_TABLES; + +const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => { + const channel = database.get(CHANNEL).findAndObserve(channelId); + const myChannel = database.get(MY_CHANNEL).findAndObserve(channelId); + const me = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => database.get(USER).findAndObserve(value)), + ); + + const roles = combineLatest([me, myChannel]).pipe( + switchMap(([{roles: userRoles}, {roles: memberRoles}]) => { + const combinedRoles = userRoles.split(' ').concat(memberRoles.split(' ')); + return database.get(ROLE).query(Q.where('name', Q.oneOf(combinedRoles))).observe(); + }), + ); + + return { + channel, + roles, + }; +}); + +export default withDatabase(enhanced(Intro)); diff --git a/app/screens/channel/intro/intro.tsx b/app/screens/channel/intro/intro.tsx new file mode 100644 index 0000000000..bb50706a41 --- /dev/null +++ b/app/screens/channel/intro/intro.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {ActivityIndicator, Platform, StyleSheet, View} from 'react-native'; + +import {General} from '@constants'; +import {useTheme} from '@context/theme'; + +import DirectChannel from './direct_channel'; +import PublicOrPrivateChannel from './public_or_private_channel'; +import TownSquare from './townsquare'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type RoleModel from '@typings/database/models/servers/role'; + +type Props = { + channel: ChannelModel; + loading?: boolean; + roles: RoleModel[]; +} + +const styles = StyleSheet.create({ + container: { + marginVertical: 12, + overflow: 'hidden', + ...Platform.select({ + android: { + scaleY: -1, + }, + }), + }, +}); + +const Intro = ({channel, loading = false, roles}: Props) => { + const theme = useTheme(); + const element = useMemo(() => { + if (channel.type === General.OPEN_CHANNEL && channel.name === General.DEFAULT_CHANNEL) { + return ( + + ); + } + + switch (channel.type) { + case General.OPEN_CHANNEL: + case General.PRIVATE_CHANNEL: + return ( + + ); + default: + return ( + + ); + } + }, [channel, roles, theme]); + + if (loading) { + return ( + + ); + } + + return ( + + {element} + + ); +}; + +export default Intro; diff --git a/app/screens/channel/intro/options/favorite/favorite.tsx b/app/screens/channel/intro/options/favorite/favorite.tsx new file mode 100644 index 0000000000..7c6bcdd6d1 --- /dev/null +++ b/app/screens/channel/intro/options/favorite/favorite.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import {saveFavoriteChannel} from '@actions/remote/preference'; +import {useServerUrl} from '@context/server'; + +import OptionItem from '../item'; + +type Props = { + channelId: string; + isFavorite: boolean; + theme: Theme; +} + +const IntroFavorite = ({channelId, isFavorite, theme}: Props) => { + const {formatMessage} = useIntl(); + const serverUrl = useServerUrl(); + + const toggleFavorite = useCallback(() => { + saveFavoriteChannel(serverUrl, channelId, !isFavorite); + }, [channelId, isFavorite]); + + return ( + + ); +}; + +export default IntroFavorite; diff --git a/app/screens/channel/intro/options/favorite/index.ts b/app/screens/channel/intro/options/favorite/index.ts new file mode 100644 index 0000000000..5f4a0eec3f --- /dev/null +++ b/app/screens/channel/intro/options/favorite/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES} from '@app/constants/database'; +import {Preferences} from '@constants'; + +import FavoriteItem from './favorite'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PreferenceModel from '@typings/database/models/servers/preference'; + +const enhanced = withObservables([], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => ({ + isFavorite: database.get(MM_TABLES.SERVER.PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_FAVORITE_CHANNEL), + Q.where('name', channelId), + ).observeWithColumns(['value']).pipe( + switchMap((prefs) => { + return prefs.length ? of$(prefs[0].value === 'true') : of$(false); + }), + ), +})); + +export default withDatabase(enhanced(FavoriteItem)); diff --git a/app/screens/channel/intro/options/index.tsx b/app/screens/channel/intro/options/index.tsx new file mode 100644 index 0000000000..774791f611 --- /dev/null +++ b/app/screens/channel/intro/options/index.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; + +import {Screens} from '@constants'; +import {showModal} from '@screens/navigation'; + +import IntroFavorite from './favorite'; +import OptionItem from './item'; + +type Props = { + channelId: string; + header?: boolean; + favorite?: boolean; + people?: boolean; + theme: Theme; +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + flexDirection: 'row', + marginBottom: 8, + marginTop: 28, + width: '100%', + }, +}); + +const IntroOptions = ({channelId, header, favorite, people, theme}: Props) => { + const {formatMessage} = useIntl(); + + const onAddPeople = useCallback(() => { + const title = formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'}); + showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId}); + }, []); + + const onSetHeader = useCallback(() => { + const title = formatMessage({id: 'screens.channel_edit', defaultMessage: 'Edit Channel'}); + showModal(Screens.CHANNEL_EDIT, title, {channelId}); + }, []); + + const onDetails = useCallback(() => { + const title = formatMessage({id: 'screens.channel_details', defaultMessage: 'Channel Details'}); + showModal(Screens.CHANNEL_DETAILS, title, {channelId}); + }, []); + + return ( + + {people && + + } + {header && + + } + {favorite && + + } + + + ); +}; + +export default IntroOptions; diff --git a/app/screens/channel/intro/options/item.tsx b/app/screens/channel/intro/options/item.tsx new file mode 100644 index 0000000000..e36ae444d4 --- /dev/null +++ b/app/screens/channel/intro/options/item.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Pressable, PressableStateCallbackType, Text} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + applyMargin?: boolean; + color?: string; + iconName: string; + label: string; + onPress: () => void; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 4, + height: 70, + justifyContent: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + width: 112, + }, + containerPressed: { + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + }, + label: { + marginTop: 6, + ...typography('Body', 50, 'SemiBold'), + }, + margin: { + marginRight: 8, + }, +})); + +const IntroItem = ({applyMargin, color, iconName, label, onPress, theme}: Props) => { + const styles = getStyleSheet(theme); + const pressedStyle = useCallback(({pressed}: PressableStateCallbackType) => { + const style = [styles.container]; + if (pressed) { + style.push(styles.containerPressed); + } + + if (applyMargin) { + style.push(styles.margin); + } + + return style; + }, [applyMargin, theme]); + + return ( + + {({pressed}) => ( + <> + + + {label} + + + )} + + ); +}; + +export default IntroItem; diff --git a/app/screens/channel/intro/public_or_private_channel/index.ts b/app/screens/channel/intro/public_or_private_channel/index.ts new file mode 100644 index 0000000000..39c657301c --- /dev/null +++ b/app/screens/channel/intro/public_or_private_channel/index.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest, of as of$} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {displayUsername} from '@utils/user'; + +import PublicOrPrivateChannel from './public_or_private_channel'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {PREFERENCE, SYSTEM, USER}} = MM_TABLES; + +const enhanced = withObservables([], ({channel, database}: {channel: ChannelModel} & WithDatabaseArgs) => { + let creator; + if (channel.creatorId) { + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap(({value}) => of$(value as ClientConfig))); + const license = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(switchMap(({value}) => of$(value as ClientLicense))); + const preferences = database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(); + const me = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => database.get(USER).findAndObserve(value)), + ); + + const profile = channel.creator.observe(); + const teammateNameDisplay = combineLatest([preferences, config, license]).pipe( + map(([prefs, cfg, lcs]) => getTeammateNameDisplaySetting(prefs, cfg, lcs)), + ); + creator = combineLatest([profile, teammateNameDisplay, me]).pipe( + map(([user, displaySetting, currentUser]) => (user ? displayUsername(user as UserModel, currentUser.locale, displaySetting, true) : '')), + ); + } else { + creator = of$(undefined); + } + + return { + creator, + }; +}); + +export default withDatabase(enhanced(PublicOrPrivateChannel)); diff --git a/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx b/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx new file mode 100644 index 0000000000..a85f3da91c --- /dev/null +++ b/app/screens/channel/intro/public_or_private_channel/public_or_private_channel.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {fetchChannelCreator} from '@actions/remote/channel'; +import CompassIcon from '@app/components/compass_icon'; +import {General, Permissions} from '@constants'; +import {useServerUrl} from '@context/server'; +import {t} from '@i18n'; +import {hasPermission} from '@utils/role'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import PrivateChannel from '../illustration/private'; +import PublicChannel from '../illustration/public'; +import IntroOptions from '../options'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type RoleModel from '@typings/database/models/servers/role'; + +type Props = { + channel: ChannelModel; + creator?: string; + roles: RoleModel[]; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + }, + created: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 50, 'Regular'), + }, + icon: { + marginRight: 5, + }, + message: { + color: theme.centerChannelColor, + marginTop: 16, + textAlign: 'center', + ...typography('Body', 200, 'Regular'), + }, + title: { + color: theme.centerChannelColor, + marginTop: 16, + marginBottom: 8, + ...typography('Heading', 700, 'SemiBold'), + }, +})); + +const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const styles = getStyleSheet(theme); + const illustration = useMemo(() => { + if (channel.type === General.OPEN_CHANNEL) { + return ; + } + + return ; + }, [channel.type, theme]); + + useEffect(() => { + if (!creator && channel.creatorId) { + fetchChannelCreator(serverUrl, channel.id); + } + }, []); + + const canManagePeople = useMemo(() => { + const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS : Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS; + return hasPermission(roles, permission, false); + }, [channel.type, roles]); + + const canSetHeader = useMemo(() => { + const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES : Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES; + return hasPermission(roles, permission, false); + }, [channel.type, roles]); + + const createdBy = useMemo(() => { + const id = channel.type === General.OPEN_CHANNEL ? t('intro.public_channel') : t('intro.private_channel'); + const defaultMessage = channel.type === General.OPEN_CHANNEL ? 'Public Channel' : 'Private Channel'; + const channelType = `${intl.formatMessage({id, defaultMessage})} `; + + const date = intl.formatDate(channel.createAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const by = intl.formatMessage({id: 'intro.created_by', defaultMessage: 'created by {creator} on {date}.'}, { + creator, + date, + }); + + return `${channelType} ${by}`; + }, [channel.type, creator, theme]); + + const message = useMemo(() => { + const id = channel.type === General.OPEN_CHANNEL ? t('intro.welcome.public') : t('intro.welcome.private'); + const msg = channel.type === General.OPEN_CHANNEL ? 'Add some more team members to the channel or start a conversation below.' : 'Only invited members can see messages posted in this private channel.'; + const mainMessage = intl.formatMessage({ + id: 'intro.welcome', + defaultMessage: 'Welcome to {displayName} channel.', + }, {displayName: channel.displayName}); + + const suffix = intl.formatMessage({id, defaultMessage: msg}); + + return `${mainMessage} ${suffix}`; + }, [channel.displayName, channel.type, theme]); + + return ( + + {illustration} + + {channel.displayName} + + + + + {createdBy} + + + + {message} + + + + ); +}; + +export default PublicOrPrivateChannel; diff --git a/app/screens/channel/intro/townsquare/index.tsx b/app/screens/channel/intro/townsquare/index.tsx new file mode 100644 index 0000000000..04fb5ab04a --- /dev/null +++ b/app/screens/channel/intro/townsquare/index.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {Permissions} from '@constants'; +import {hasPermission} from '@utils/role'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import PublicChannel from '../illustration/public'; +import IntroOptions from '../options'; + +import type RoleModel from '@typings/database/models/servers/role'; + +type Props = { + channelId: string; + displayName: string; + roles: RoleModel[]; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + }, + message: { + color: theme.centerChannelColor, + marginTop: 16, + textAlign: 'center', + ...typography('Body', 200, 'Regular'), + width: '100%', + }, + title: { + color: theme.centerChannelColor, + marginTop: 16, + ...typography('Heading', 700, 'SemiBold'), + }, +})); + +const TownSquare = ({channelId, displayName, roles, theme}: Props) => { + const styles = getStyleSheet(theme); + return ( + + + + {displayName} + + + + + ); +}; + +export default TownSquare; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 916f4d794b..e4640fd7c3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -9,7 +9,6 @@ "about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.", "about.teamEditiont0": "Team Edition", "about.teamEditiont1": "Enterprise Edition", - "about.title": "About {appTitle}", "account.logout": "Log out", "account.logout_from": "Log out of {serverName}", "account.saved_messages": "Saved Messages", @@ -109,6 +108,19 @@ "failed_action.fetch_teams": "An error ocurred while loading the teams of this server", "failed_action.something_wrong": "Something went wrong", "failed_action.try_again": "Try again", + "intro.add_people": "Add People", + "intro.channel_details": "Details", + "intro.created_by": "created by {creator} on {date}.", + "intro.direct_message": "This is the start of your conversation with {teammate}. Messages and files shared here are not shown to anyone else.", + "intro.favorite": "Favorite", + "intro.group_message": "This is the start of your conversation with this group. Messages and files shared here are not shown to anyone else outside of the group.", + "intro.private_channel": "Private Channel", + "intro.public_channel": "Public Channel", + "intro.set_header": "Set Header", + "intro.townsquare": "Welcome to {name}. Everyone automatically becomes a member of this channel when they join the team.", + "intro.welcome": "Welcome to {displayName} channel.", + "intro.welcome.private": "Only invited members can see messages posted in this private channel.", + "intro.welcome.public": "Add some more team members to the channel or start a conversation below.", "last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.", "last_users_message.added_to_team.type": "were **added to the team** by {actor}.", "last_users_message.first": "{firstUser} and ", @@ -299,6 +311,8 @@ "screen.mentions.subtitle": "Messages you've been mentioned in", "screen.mentions.title": "Recent Mentions", "screen.search.placeholder": "Search messages & files", + "screens.channel_details": "Channel Details", + "screens.channel_edit": "Edit Channel", "search_bar.search": "Search", "status_dropdown.set_away": "Away", "status_dropdown.set_dnd": "Do Not Disturb", diff --git a/types/api/channels.d.ts b/types/api/channels.d.ts index b5999ac61d..5cb0ce6eca 100644 --- a/types/api/channels.d.ts +++ b/types/api/channels.d.ts @@ -41,6 +41,11 @@ type ChannelWithTeamData = Channel & { team_name: string; team_update_at: number; } +type ChannelMember = { + id?: string; + channel_id: string; + user_id: string; +} type ChannelMembership = { id?: string; channel_id: string; diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 09fa04b0d8..1fe3027f53 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -222,7 +222,7 @@ export type HandleGroupArgs = PrepareOnly & { }; export type HandleChannelMembershipArgs = PrepareOnly & { - channelMemberships: ChannelMembership[]; + channelMemberships: ChannelMember[]; }; export type HandleGroupMembershipArgs = PrepareOnly & { diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index 19566a7203..a39db483ab 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -97,6 +97,7 @@ type RawValue = | AppInfo | Channel | ChannelInfo + | ChannelMember | ChannelMembership | CustomEmoji | Draft