From b8b51296c0518b8e4249048dbc1c2ce4e4bb57ed Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 17 Jan 2022 07:06:26 -0300 Subject: [PATCH] [Gekidou] post list (#5893) --- app/actions/local/channel.ts | 30 +- app/actions/local/post.ts | 2 +- app/actions/local/team.ts | 53 +-- app/actions/remote/channel.ts | 56 ++- app/actions/remote/entry/app.ts | 26 +- app/actions/remote/entry/common.ts | 4 +- app/actions/remote/post.ts | 88 +++- app/actions/remote/search.ts | 3 +- app/actions/remote/team.ts | 52 ++- app/actions/websocket/teams.ts | 6 +- app/client/rest/posts.ts | 8 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../channel_list/categories/index.test.tsx | 24 +- .../channel_list/threads/index.test.tsx | 24 +- app/components/channel_list/threads/index.tsx | 41 +- app/components/formatted_date/index.tsx | 2 +- app/components/jumbo_emoji/index.tsx | 2 +- app/components/navigation_header/context.tsx | 8 +- app/components/navigation_header/header.tsx | 66 +-- app/components/navigation_header/index.tsx | 12 +- app/components/navigation_header/large.tsx | 4 +- app/components/navigation_header/search.tsx | 6 +- .../navigation_header/search_context.tsx | 4 +- .../combined_user_activity.tsx | 6 +- app/components/post_list/config.ts | 50 +++ .../post_list/date_separator/index.tsx | 14 +- app/components/post_list/index.tsx | 391 +++++++++++++++--- .../post_list/more_messages/index.ts | 30 ++ .../post_list/more_messages/more_messages.tsx | 269 ++++++++++++ .../post_list/new_message_line/index.tsx | 48 +-- .../post_list/post/avatar/index.tsx | 40 +- .../post_list/post/body/files/index.tsx | 4 +- app/components/post_list/post/body/index.tsx | 3 +- .../post/header/display_name/index.tsx | 5 +- .../post_list/post/header/header.tsx | 4 +- app/components/post_list/post/index.ts | 27 +- app/components/post_list/post/post.tsx | 47 ++- .../post_list/post/pre_header/index.tsx | 5 +- .../post/system_message/system_message.tsx | 21 +- app/components/post_list/refresh_control.tsx | 41 ++ app/components/search/index.tsx | 8 +- app/components/system_avatar/index.tsx | 21 +- app/components/system_header/index.tsx | 10 +- .../team_sidebar/team_list/index.ts | 2 +- .../team_list/team_item/team_item.tsx | 74 ++-- app/constants/events.ts | 1 + app/constants/navigation.ts | 1 + app/constants/screens.ts | 8 +- app/constants/view.ts | 7 +- app/database/models/server/post.ts | 13 + .../handlers/posts_in_channel.ts | 44 +- app/hooks/header.ts | 2 + app/screens/channel/channel.tsx | 144 +++++++ .../channel_display_name/index.tsx | 57 --- .../channel_guest_label/index.tsx | 71 ---- .../channel_nav_bar/channel_title/index.tsx | 202 --------- app/screens/channel/channel_nav_bar/index.tsx | 69 ---- .../channel_post_list/channel_post_list.tsx | 69 ++++ .../channel/channel_post_list/index.ts | 71 ++++ .../intro/direct_channel/direct_channel.tsx | 152 +++++++ .../intro/direct_channel/group/group.tsx | 78 ++++ .../intro/direct_channel/group/index.ts | 21 + .../intro/direct_channel/index.ts | 45 ++ .../intro/direct_channel/member/index.ts | 15 + .../intro/direct_channel/member/member.tsx | 74 ++++ .../intro/illustration/private.tsx | 272 ++++++++++++ .../intro/illustration/public.tsx | 358 ++++++++++++++++ .../channel/channel_post_list/intro/index.ts | 43 ++ .../channel/channel_post_list/intro/intro.tsx | 96 +++++ .../intro/options/favorite/favorite.tsx | 38 ++ .../intro/options/favorite/index.ts | 29 ++ .../channel_post_list/intro/options/index.tsx | 86 ++++ .../channel_post_list/intro/options/item.tsx | 79 ++++ .../intro/public_or_private_channel/index.ts | 51 +++ .../public_or_private_channel.tsx | 145 +++++++ .../intro/townsquare/index.tsx | 66 +++ app/screens/channel/index.tsx | 171 ++++---- app/screens/channel/intro/options/item.tsx | 33 +- .../channel/other_mentions_badge/index.tsx | 120 ++++++ .../home/channel_list/channel_list.tsx | 18 +- app/screens/home/index.tsx | 10 +- .../components/channel_info/channel_info.tsx | 2 +- .../components/mention/mention.tsx | 32 +- app/screens/home/search/index.tsx | 2 + app/screens/home/tab_bar/index.tsx | 27 +- app/screens/navigation.ts | 9 +- app/screens/server/form.tsx | 1 - app/utils/emoji/helpers.ts | 2 +- app/utils/post/index.ts | 16 +- app/utils/theme/index.ts | 3 +- assets/base/i18n/en.json | 5 +- package-lock.json | 21 + package.json | 1 + patches/react-native+0.66.4.patch | 163 +++++++- types/api/channels.d.ts | 1 + types/api/posts.d.ts | 48 +-- types/database/models/servers/post.d.ts | 3 + 97 files changed, 3787 insertions(+), 951 deletions(-) create mode 100644 app/components/post_list/config.ts create mode 100644 app/components/post_list/more_messages/index.ts create mode 100644 app/components/post_list/more_messages/more_messages.tsx create mode 100644 app/components/post_list/refresh_control.tsx create mode 100644 app/screens/channel/channel.tsx delete mode 100644 app/screens/channel/channel_nav_bar/channel_title/channel_display_name/index.tsx delete mode 100644 app/screens/channel/channel_nav_bar/channel_title/channel_guest_label/index.tsx delete mode 100644 app/screens/channel/channel_nav_bar/channel_title/index.tsx delete mode 100644 app/screens/channel/channel_nav_bar/index.tsx create mode 100644 app/screens/channel/channel_post_list/channel_post_list.tsx create mode 100644 app/screens/channel/channel_post_list/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx create mode 100644 app/screens/channel/channel_post_list/intro/direct_channel/group/group.tsx create mode 100644 app/screens/channel/channel_post_list/intro/direct_channel/group/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/direct_channel/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/direct_channel/member/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/direct_channel/member/member.tsx create mode 100644 app/screens/channel/channel_post_list/intro/illustration/private.tsx create mode 100644 app/screens/channel/channel_post_list/intro/illustration/public.tsx create mode 100644 app/screens/channel/channel_post_list/intro/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/intro.tsx create mode 100644 app/screens/channel/channel_post_list/intro/options/favorite/favorite.tsx create mode 100644 app/screens/channel/channel_post_list/intro/options/favorite/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/options/index.tsx create mode 100644 app/screens/channel/channel_post_list/intro/options/item.tsx create mode 100644 app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts create mode 100644 app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx create mode 100644 app/screens/channel/channel_post_list/intro/townsquare/index.tsx create mode 100644 app/screens/channel/other_mentions_badge/index.tsx diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index f5d01235d8..c775de554f 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -7,7 +7,7 @@ import {DeviceEventEmitter} from 'react-native'; import {Navigation as NavigationConstants, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {prepareDeleteChannel, queryAllMyChannelIds, queryChannelsById, queryMyChannel} from '@queries/servers/channel'; -import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, queryCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system'; +import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, queryCommonSystemValues, queryCurrentTeamId, setCurrentChannelId} from '@queries/servers/system'; import {addChannelToTeamHistory, addTeamToTeamHistory, removeChannelFromTeamHistory} from '@queries/servers/team'; import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation'; import {isTablet} from '@utils/helpers'; @@ -31,6 +31,11 @@ export const switchToChannel = async (serverUrl: string, channelId: string, team const {operator} = DatabaseManager.serverDatabases[serverUrl]; const models = []; const commonValues: PrepareCommonSystemValuesArgs = {currentChannelId: channelId}; + if (isTabletDevice) { + // On tablet, the channel is being rendered, by setting the channel to empty first we speed up + // the switch by ~3x + await setCurrentChannelId(operator, ''); + } if (teamId && system.currentTeamId !== teamId) { commonValues.currentTeamId = teamId; @@ -168,3 +173,26 @@ export const markChannelAsViewed = async (serverUrl: string, channelId: string, return {error}; } }; + +export const resetMessageCount = async (serverUrl: string, channelId: string) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const member = await queryMyChannel(operator.database, channelId); + if (!member) { + return {error: 'not a member'}; + } + + try { + member.prepareUpdate((m) => { + m.messageCount = 0; + }); + await operator.batchRecords([member]); + + return member; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 6b4de31940..33a7d5f20a 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -132,7 +132,7 @@ export const selectAttachmentMenuAction = (serverUrl: string, postId: string, ac return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption); }; -export const processPostsFetched = async (serverUrl: string, actionType: string, data: {order: string[]; posts: Post[]; prev_post_id?: string}, fetchOnly = false) => { +export const processPostsFetched = async (serverUrl: string, actionType: string, data: PostResponse, fetchOnly = false) => { const order = data.order; const posts = Object.values(data.posts) as Post[]; const previousPostId = data.prev_post_id; diff --git a/app/actions/local/team.ts b/app/actions/local/team.ts index 07ad0fa7fb..ef46c3be81 100644 --- a/app/actions/local/team.ts +++ b/app/actions/local/team.ts @@ -1,59 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {DeviceEventEmitter} from 'react-native'; - -import {fetchMyChannelsForTeam} from '@actions/remote/channel'; -import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post'; -import {fetchAllTeams} from '@actions/remote/team'; -import Events from '@constants/events'; import DatabaseManager from '@database/manager'; -import {prepareCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system'; -import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory, queryLastChannelFromTeam, addTeamToTeamHistory} from '@queries/servers/team'; -import {isTablet} from '@utils/helpers'; +import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory} from '@queries/servers/team'; import type TeamModel from '@typings/database/models/servers/team'; -export const handleTeamChange = async (serverUrl: string, teamId: string) => { - const {operator, database} = DatabaseManager.serverDatabases[serverUrl]; - const currentTeamId = await queryCurrentTeamId(database); - - if (currentTeamId === teamId) { - return; - } - - let channelId = ''; - if (await isTablet()) { - channelId = await queryLastChannelFromTeam(database, teamId); - if (channelId) { - fetchPostsForChannel(serverUrl, channelId); - } - } - const models = []; - const system = await prepareCommonSystemValues(operator, {currentChannelId: channelId, currentTeamId: teamId}); - if (system?.length) { - models.push(...system); - } - const history = await addTeamToTeamHistory(operator, teamId, true); - if (history.length) { - models.push(...history); - } - - if (models.length) { - operator.batchRecords(models); - } - - const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId); - if (error) { - DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error); - } - - if (channels?.length && memberships?.length) { - fetchPostsForUnreadChannels(serverUrl, channels, memberships, channelId); - } -}; - -export const localRemoveUserFromTeam = async (serverUrl: string, teamId: string) => { +export const removeUserFromTeam = async (serverUrl: string, teamId: string) => { const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; if (!serverDatabase) { return; @@ -77,7 +30,5 @@ export const localRemoveUserFromTeam = async (serverUrl: string, teamId: string) console.log('FAILED TO BATCH CHANGES FOR REMOVE USER FROM TEAM'); } } - - fetchAllTeams(serverUrl); } }; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 556988d14c..37b074907f 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -18,12 +18,14 @@ import TeamModel from '@typings/database/models/servers/team'; import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; +import {fetchPostsForChannel} from './post'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team'; import {fetchProfilesPerChannels, fetchUsersByIds} from './user'; import type {Client} from '@client/rest'; +import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; export type MyChannelsRequest = { channels?: Channel[]; @@ -144,6 +146,44 @@ export const fetchChannelCreator = async (serverUrl: string, channelId: string, } }; +export const fetchChannelStats = 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 stats = await client.getChannelStats(channelId); + if (!fetchOnly) { + const channel = await queryChannelById(operator.database, channelId); + if (channel) { + const channelInfo = await channel.info.fetch() as ChannelInfoModel; + const channelInfos: ChannelInfo[] = [{ + guest_count: stats.guest_count, + header: channelInfo.header, + id: channelId, + member_count: stats.member_count, + pinned_post_count: stats.pinnedpost_count, + purpose: channelInfo.purpose, + }]; + await operator.handleChannelInfo({channelInfos, prepareRecordsOnly: false}); + } + } + + return {stats}; + } 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 { @@ -503,7 +543,7 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri } if (teamId && channelId) { - await switchToChannel(serverUrl, channelId, teamId); + await switchToChannelById(serverUrl, channelId, teamId); } if (roles.length) { @@ -516,3 +556,17 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri return {error}; } }; + +export const switchToChannelById = async (serverUrl: string, channelId: string, teamId?: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + fetchPostsForChannel(serverUrl, channelId); + await switchToChannel(serverUrl, channelId, teamId); + markChannelAsRead(serverUrl, channelId); + fetchChannelStats(serverUrl, channelId); + + return {}; +}; diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index f47ede703f..1b146ca96c 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {switchToChannelById} from '@actions/remote/channel'; import {fetchRoles} from '@actions/remote/role'; import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; import {queryChannelsById, queryDefaultChannelForTeam} from '@queries/servers/channel'; import {prepareModels} from '@queries/servers/entry'; -import {prepareCommonSystemValues, queryCommonSystemValues, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {prepareCommonSystemValues, queryCommonSystemValues, queryCurrentChannelId, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; import {deleteMyTeams, queryTeamsById} from '@queries/servers/team'; import {queryCurrentUser} from '@queries/servers/user'; import {deleteV1Data} from '@utils/file'; @@ -21,6 +22,7 @@ export const appEntry = async (serverUrl: string) => { } const {database} = operator; + const tabletDevice = await isTablet(); const currentTeamId = await queryCurrentTeamId(database); const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId); const fetchedError = (fetchedData as AppEntryError).error; @@ -31,15 +33,31 @@ export const appEntry = async (serverUrl: string) => { const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData; - if (initialTeamId !== currentTeamId) { + if (initialTeamId === currentTeamId) { + let cId = await queryCurrentChannelId(database); + if (tabletDevice) { + if (!cId) { + const channel = await queryDefaultChannelForTeam(database, initialTeamId); + if (channel) { + cId = channel.id; + } + } + + switchToChannelById(serverUrl, cId, initialTeamId); + } + } else { // Immediately set the new team as the current team in the database so that the UI // renders the correct team. let channelId = ''; - if ((await isTablet())) { + if (tabletDevice) { const channel = await queryDefaultChannelForTeam(database, initialTeamId); channelId = channel?.id || ''; } - setCurrentTeamAndChannelId(operator, initialTeamId, channelId); + if (channelId) { + switchToChannelById(serverUrl, channelId, initialTeamId); + } else { + setCurrentTeamAndChannelId(operator, initialTeamId, channelId); + } } let removeTeams; diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index b3080b8264..8966f4eced 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -81,7 +81,7 @@ export const fetchAppEntryData = async (serverUrl: string, initialTeamId: string removeTeamIds, }; - if (teamData.teams?.length === 0) { + if (teamData.teams?.length === 0 && !teamData.error) { // User is no longer a member of any team const myTeams = await queryMyTeams(database); removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || [])); @@ -95,7 +95,7 @@ export const fetchAppEntryData = async (serverUrl: string, initialTeamId: string const inTeam = teamData.teams?.find((t) => t.id === initialTeamId); const chError = chData?.error as ClientError | undefined; - if (!inTeam || chError?.status_code === 403) { + if ((!inTeam && !teamData.error) || chError?.status_code === 403) { // User is no longer a member of the current team if (!removeTeamIds.includes(initialTeamId)) { removeTeamIds.push(initialTeamId); diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index f5b50525d4..4c7139d818 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. // -import Model from '@nozbe/watermelondb/Model'; + +import {DeviceEventEmitter} from 'react-native'; import {processPostsFetched} from '@actions/local/post'; -import {ActionType, General} from '@constants'; +import {ActionType, Events, General} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getNeededAtMentionedUsernames} from '@helpers/api/user'; @@ -17,6 +18,7 @@ import {queryAllUsers} from '@queries/servers/user'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; +import type Model from '@nozbe/watermelondb/Model'; type PostsRequest = { error?: unknown; @@ -64,19 +66,38 @@ export const fetchPostsForChannel = async (serverUrl: string, channelId: string) } if (data.posts?.length && data.order?.length) { + const models: Model[] = []; try { - await fetchPostAuthors(serverUrl, data.posts, false); + const {authors} = await fetchPostAuthors(serverUrl, data.posts, true); + if (authors?.length) { + const users = await operator.handleUsers({ + users: authors, + prepareRecordsOnly: true, + }); + if (users.length) { + models.push(...users); + } + } } catch (error) { // eslint-disable-next-line no-console console.log('FETCH AUTHORS ERROR', error); } - operator.handlePosts({ + const postModels = await operator.handlePosts({ actionType, order: data.order, posts: data.posts, previousPostId: data.previousPostId, + prepareRecordsOnly: true, }); + + if (postModels.length) { + models.push(...postModels); + } + + if (models.length) { + await operator.batchRecords(models); + } } return {posts: data.posts}; @@ -119,6 +140,65 @@ export const fetchPosts = async (serverUrl: string, channelId: string, page = 0, } }; +export const fetchPostsBefore = async (serverUrl: string, channelId: string, postId: string, perPage = General.POST_CHUNK_SIZE, 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}; + } + + const activeServerUrl = await DatabaseManager.getActiveServerUrl(); + + try { + if (activeServerUrl === serverUrl) { + DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true); + } + const data = await client.getPostsBefore(channelId, postId, 0, perPage); + const result = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_BEFORE, data, true); + + if (activeServerUrl === serverUrl) { + DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, false); + } + + if (result.posts.length && !fetchOnly) { + try { + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_BEFORE, + ...result, + prepareRecordsOnly: true, + }); + const {authors} = await fetchPostAuthors(serverUrl, result.posts, true); + if (authors?.length) { + const userModels = await operator.handleUsers({ + users: authors, + prepareRecordsOnly: true, + }); + models.push(...userModels); + } + + await operator.batchRecords(models); + } catch (error) { + // eslint-disable-next-line no-console + console.log('FETCH AUTHORS ERROR', error); + } + } + + return result; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + if (activeServerUrl === serverUrl) { + DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true); + } + return {error}; + } +}; + export const fetchPostsSince = async (serverUrl: string, channelId: string, since: number, fetchOnly = false): Promise => { let client: Client; try { diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index f0033ad511..467e192b02 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -1,8 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import Model from '@nozbe/watermelondb/Model'; - import {processPostsFetched} from '@actions/local/post'; import {prepareMissingChannelsForAllTeams} from '@app/queries/servers/channel'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; @@ -14,6 +12,7 @@ import {fetchPostAuthors, getMissingChannelsFromPosts} from './post'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; +import type Model from '@nozbe/watermelondb/Model'; type PostSearchRequest = { error?: unknown; diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 065bf50759..0b80dc7f4b 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -2,16 +2,18 @@ // See LICENSE.txt for license information. import {Model} from '@nozbe/watermelondb'; +import {DeviceEventEmitter} from 'react-native'; -import {localRemoveUserFromTeam} from '@actions/local/team'; +import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team'; +import {Events} from '@constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; import {prepareMyChannelsForTeam, queryDefaultChannelForTeam} from '@queries/servers/channel'; -import {queryWebSocketLastDisconnected} from '@queries/servers/system'; -import {prepareDeleteTeam, prepareMyTeams, queryTeamsById, syncTeamTable} from '@queries/servers/team'; +import {prepareCommonSystemValues, queryCurrentTeamId, queryWebSocketLastDisconnected} from '@queries/servers/system'; +import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, queryLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team'; import {isTablet} from '@utils/helpers'; -import {fetchMyChannelsForTeam} from './channel'; +import {fetchMyChannelsForTeam, switchToChannelById} from './channel'; import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; @@ -250,6 +252,7 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user if (!fetchOnly) { localRemoveUserFromTeam(serverUrl, teamId); + fetchAllTeams(serverUrl); } return {error: undefined}; @@ -258,3 +261,44 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user return {error}; } }; + +export const handleTeamChange = async (serverUrl: string, teamId: string) => { + const {operator, database} = DatabaseManager.serverDatabases[serverUrl]; + const currentTeamId = await queryCurrentTeamId(database); + + if (currentTeamId === teamId) { + return; + } + + let channelId = ''; + if (await isTablet()) { + channelId = await queryLastChannelFromTeam(database, teamId); + if (channelId) { + await switchToChannelById(serverUrl, channelId, teamId); + return; + } + } + + const models = []; + const system = await prepareCommonSystemValues(operator, {currentChannelId: channelId, currentTeamId: teamId}); + if (system?.length) { + models.push(...system); + } + const history = await addTeamToTeamHistory(operator, teamId, true); + if (history.length) { + models.push(...history); + } + + if (models.length) { + await operator.batchRecords(models); + } + + const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId); + if (error) { + DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error); + } + + if (channels?.length && memberships?.length) { + fetchPostsForUnreadChannels(serverUrl, channels, memberships, channelId); + } +}; diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts index e64ed652bc..19b34052fc 100644 --- a/app/actions/websocket/teams.ts +++ b/app/actions/websocket/teams.ts @@ -3,7 +3,8 @@ import {DeviceEventEmitter} from 'react-native'; -import {handleTeamChange, localRemoveUserFromTeam} from '@actions/local/team'; +import {removeUserFromTeam} from '@actions/local/team'; +import {fetchAllTeams, handleTeamChange} from '@actions/remote/team'; import {updateUsersNoLongerVisible} from '@actions/remote/user'; import Events from '@constants/events'; import DatabaseManager from '@database/manager'; @@ -27,7 +28,8 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: any) { } if (user.id === msg.data.user_id) { - localRemoveUserFromTeam(serverUrl, msg.data.team_id); + await removeUserFromTeam(serverUrl, msg.data.team_id); + fetchAllTeams(serverUrl); if (isGuest(user.roles)) { updateUsersNoLongerVisible(serverUrl); diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 8b173a101f..158bbfaf4d 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -12,10 +12,10 @@ export interface ClientPostsMix { patchPost: (postPatch: Partial & {id: string}) => Promise; deletePost: (postId: string) => Promise; getPostThread: (postId: string) => Promise; - getPosts: (channelId: string, page?: number, perPage?: number) => Promise; - getPostsSince: (channelId: string, since: number) => Promise; - getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; - getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; + getPosts: (channelId: string, page?: number, perPage?: number) => Promise; + getPostsSince: (channelId: string, since: number) => Promise; + getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; + getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; getFileInfosForPost: (postId: string) => Promise; getFlaggedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise; getPinnedPosts: (channelId: string) => Promise; diff --git a/app/components/channel_list/categories/__snapshots__/index.test.tsx.snap b/app/components/channel_list/categories/__snapshots__/index.test.tsx.snap index 2d0e3b41a4..4cba9de3c5 100644 --- a/app/components/channel_list/categories/__snapshots__/index.test.tsx.snap +++ b/app/components/channel_list/categories/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Category List Component should match snapshot 1`] = ` +exports[`Category List Component should match snapshot 1`] = ` { - const {toJSON} = renderWithIntlAndTheme( - , - ); +describe('Category List Component ', () => { + let database: Database | undefined; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); - expect(toJSON()).toMatchSnapshot(); + test('should match snapshot', () => { + const {toJSON} = renderWithEverything( + , + {database}, + ); + + expect(toJSON()).toMatchSnapshot(); + }); }); diff --git a/app/components/channel_list/threads/index.test.tsx b/app/components/channel_list/threads/index.test.tsx index daa5cf9fb2..0a3d4b5813 100644 --- a/app/components/channel_list/threads/index.test.tsx +++ b/app/components/channel_list/threads/index.test.tsx @@ -3,14 +3,26 @@ import React from 'react'; -import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; import Threads from './index'; -test('Threads Component should match snapshot', () => { - const {toJSON} = renderWithIntlAndTheme( - , - ); +import type Database from '@nozbe/watermelondb/Database'; - expect(toJSON()).toMatchSnapshot(); +describe('Threads Component', () => { + let database: Database | undefined; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + test('should match snapshot', () => { + const {toJSON} = renderWithEverything( + , + {database}, + ); + + expect(toJSON()).toMatchSnapshot(); + }); }); diff --git a/app/components/channel_list/threads/index.tsx b/app/components/channel_list/threads/index.tsx index 17fca262ea..d723eb6c2c 100644 --- a/app/components/channel_list/threads/index.tsx +++ b/app/components/channel_list/threads/index.tsx @@ -1,17 +1,28 @@ // 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 React from 'react'; import {StyleSheet, Text, View} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; +import {switchToChannelById} from '@actions/remote/channel'; import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {Screens} from '@constants'; +import {General} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {goToScreen} from '@screens/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +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'; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { display: 'flex', @@ -30,18 +41,19 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const textStyle = StyleSheet.create([typography('Body', 200, 'SemiBold')]); -const ThreadsButton = () => { +const ThreadsButton = ({channelId}: {channelId?: string}) => { const theme = useTheme(); + const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); /* * @to-do: - * - Check if there are threads, else return null - * - Change to button, navigate to threads view + * - Check if there are threads, else return null (think of doing this before mounting this component) + * - Change to button, navigate to threads view instead of the current team Town Square * - Add right-side number badge */ return ( - goToScreen(Screens.CHANNEL, 'Channel', {}, {topBar: {visible: false}})} > + (channelId ? switchToChannelById(serverUrl, channelId) : true)} > { ); }; -export default ThreadsButton; +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const currentTeamId = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID); + const channelId = currentTeamId.pipe( + switchMap((model) => database.get(MM_TABLES.SERVER.CHANNEL).query( + Q.where('team_id', model.value), + Q.where('name', General.DEFAULT_CHANNEL), + ).observe().pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((channels) => (channels.length ? of$(channels[0].id) : of$(undefined))), + )), + ); + + return {channelId}; +}); + +export default withDatabase(enhanced(ThreadsButton)); diff --git a/app/components/formatted_date/index.tsx b/app/components/formatted_date/index.tsx index 5bb97d6ab9..acbc3ef13f 100644 --- a/app/components/formatted_date/index.tsx +++ b/app/components/formatted_date/index.tsx @@ -11,7 +11,7 @@ type FormattedDateProps = TextProps & { value: number | string | Date; } -const FormattedDate = ({format = 'ddd, MMM DD, YYYY', timezone, value, ...props}: FormattedDateProps) => { +const FormattedDate = ({format = 'MMM DD, YYYY', timezone, value, ...props}: FormattedDateProps) => { let formattedDate = moment(value).format(format); if (timezone) { let zone = timezone as string; diff --git a/app/components/jumbo_emoji/index.tsx b/app/components/jumbo_emoji/index.tsx index bffec1be40..363f67470e 100644 --- a/app/components/jumbo_emoji/index.tsx +++ b/app/components/jumbo_emoji/index.tsx @@ -70,7 +70,7 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => { }; const renderText = ({literal}: {literal: string}) => { - return {literal}; + return renderEmoji({emojiName: literal, literal, context: []}); }; const renderNewLine = () => { diff --git a/app/components/navigation_header/context.tsx b/app/components/navigation_header/context.tsx index 2ce076573f..6bafb15284 100644 --- a/app/components/navigation_header/context.tsx +++ b/app/components/navigation_header/context.tsx @@ -11,7 +11,7 @@ type Props = { hasSearch: boolean; isLargeTitle: boolean; largeHeight: number; - scrollValue: Animated.SharedValue; + scrollValue?: Animated.SharedValue; theme: Theme; top: number; } @@ -44,14 +44,14 @@ const NavigationHeaderContext = ({ const marginTop = useAnimatedStyle(() => { const normal = defaultHeight + top; - const calculated = -(top + scrollValue.value); + const calculated = -(top + (scrollValue?.value || 0)); const searchHeight = hasSearch ? defaultHeight + 9 : 0; if (!isLargeTitle) { return {marginTop: Math.max((normal + calculated), normal)}; } - return {marginTop: Math.max((-scrollValue.value + largeHeight + searchHeight), normal)}; - }, [defaultHeight, largeHeight, isLargeTitle, hasSearch, top]); + return {marginTop: Math.max((-(scrollValue?.value || 0) + largeHeight + searchHeight), normal)}; + }, [defaultHeight, largeHeight, isLargeTitle, hasSearch]); return ( diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx index 6e35b58ec2..819d03ef41 100644 --- a/app/components/navigation_header/header.tsx +++ b/app/components/navigation_header/header.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useMemo} from 'react'; -import {Platform, Text} from 'react-native'; +import {Platform, Text, View} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; @@ -27,10 +27,12 @@ type Props = { largeHeight: number; leftComponent?: React.ReactElement; onBackPress?: () => void; + onTitlePress?: () => void; rightButtons?: HeaderRightButton[]; - scrollValue: Animated.SharedValue; + scrollValue?: Animated.SharedValue; showBackButton?: boolean; subtitle?: string; + subtitleCompanion?: React.ReactElement; theme: Theme; title?: string; top: number; @@ -45,6 +47,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ paddingHorizontal: 16, zIndex: 10, }, + subtitleContainer: { + flexDirection: 'row', + }, subtitle: { color: changeOpacity(theme.sidebarHeaderTextColor, 0.72), fontFamily: 'OpenSans', @@ -89,10 +94,12 @@ const Header = ({ largeHeight, leftComponent, onBackPress, + onTitlePress, rightButtons, scrollValue, showBackButton = true, subtitle, + subtitleCompanion, theme, title, top, @@ -109,15 +116,15 @@ const Header = ({ } const barHeight = Platform.OS === 'ios' ? (largeHeight - defaultHeight - (top / 2)) : largeHeight - defaultHeight; - const val = (top + scrollValue.value); + const val = (top + (scrollValue?.value ?? 0)); return { opacity: val >= barHeight ? withTiming(1, {duration: 250}) : 0, }; - }, [defaultHeight, largeHeight, top, isLargeTitle, hasSearch]); + }, [defaultHeight, largeHeight, isLargeTitle, hasSearch]); const containerStyle = useMemo(() => { return [styles.container, {height: defaultHeight + top, paddingTop: top}]; - }, [top, defaultHeight, theme]); + }, [defaultHeight, theme]); const additionalTitleStyle = useMemo(() => ({ marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}), @@ -144,26 +151,37 @@ const Header = ({ {leftComponent} - {!hasSearch && - - {title} - - } - {!isLargeTitle && - - {subtitle} - - } + <> + {!hasSearch && + + {title} + + } + {!isLargeTitle && + + + {subtitle} + + {subtitleCompanion} + + } + + {Boolean(rightButtons?.length) && diff --git a/app/components/navigation_header/index.tsx b/app/components/navigation_header/index.tsx index 6518444592..749fff2021 100644 --- a/app/components/navigation_header/index.tsx +++ b/app/components/navigation_header/index.tsx @@ -24,11 +24,13 @@ type Props = SearchProps & { isLargeTitle?: boolean; leftComponent?: React.ReactElement; onBackPress?: () => void; + onTitlePress?: () => void; rightButtons?: HeaderRightButton[]; - scrollValue: Animated.SharedValue; + scrollValue?: Animated.SharedValue; showBackButton?: boolean; showHeaderInContext?: boolean; subtitle?: string; + subtitleCompanion?: React.ReactElement; title?: string; } @@ -47,11 +49,13 @@ const NavigationHeader = ({ isLargeTitle = false, leftComponent, onBackPress, + onTitlePress, rightButtons, scrollValue, showBackButton, showHeaderInContext = true, subtitle, + subtitleCompanion, title = '', ...searchProps }: Props) => { @@ -62,9 +66,9 @@ const NavigationHeader = ({ const {largeHeight, defaultHeight} = useHeaderHeight(isLargeTitle, Boolean(subtitle), hasSearch); const containerHeight = useAnimatedStyle(() => { const normal = defaultHeight + insets.top; - const calculated = -(insets.top + scrollValue.value); + const calculated = -(insets.top + (scrollValue?.value || 0)); return {height: Math.max((normal + calculated), normal)}; - }, [defaultHeight, insets.top]); + }, []); return ( <> @@ -76,10 +80,12 @@ const NavigationHeader = ({ largeHeight={largeHeight} leftComponent={leftComponent} onBackPress={onBackPress} + onTitlePress={onTitlePress} rightButtons={rightButtons} scrollValue={scrollValue} showBackButton={showBackButton} subtitle={subtitle} + subtitleCompanion={subtitleCompanion} theme={theme} title={title} top={insets.top} diff --git a/app/components/navigation_header/large.tsx b/app/components/navigation_header/large.tsx index 93a1eb570b..42f4cbaa46 100644 --- a/app/components/navigation_header/large.tsx +++ b/app/components/navigation_header/large.tsx @@ -12,7 +12,7 @@ type Props = { defaultHeight: number; hasSearch: boolean; largeHeight: number; - scrollValue: Animated.SharedValue; + scrollValue?: Animated.SharedValue; subtitle?: string; theme: Theme; title: string; @@ -48,7 +48,7 @@ const NavigationHeaderLargeTitle = ({ const transform = useAnimatedStyle(() => { return { - transform: [{translateY: -(top + scrollValue.value)}], + transform: [{translateY: -(top + (scrollValue?.value || 0))}], }; }, [top]); diff --git a/app/components/navigation_header/search.tsx b/app/components/navigation_header/search.tsx index 9e2371ffbb..ed7b4e8e07 100644 --- a/app/components/navigation_header/search.tsx +++ b/app/components/navigation_header/search.tsx @@ -14,7 +14,7 @@ type Props = SearchProps & { defaultHeight: number; forwardedRef?: React.RefObject; largeHeight: number; - scrollValue: Animated.SharedValue; + scrollValue?: Animated.SharedValue; theme: Theme; top: number; } @@ -44,13 +44,13 @@ const NavigationSearch = ({ const styles = getStyleSheet(theme); const searchTop = useAnimatedStyle(() => { - return {marginTop: Math.max((-scrollValue.value + largeHeight), top)}; + return {marginTop: Math.max((-(scrollValue?.value || 0) + largeHeight), top)}; }, [defaultHeight, largeHeight, top]); const onFocus = useCallback((e) => { const searchInset = isTablet ? TABLET_HEADER_SEARCH_INSET : IOS_HEADER_SEARCH_INSET; const offset = Platform.select({android: largeHeight + ANDROID_HEADER_SEARCH_INSET, default: defaultHeight + searchInset}); - if (forwardedRef?.current && Math.abs(scrollValue.value) <= top) { + if (forwardedRef?.current && Math.abs((scrollValue?.value || 0)) <= top) { if ((forwardedRef.current as ScrollView).scrollTo) { (forwardedRef.current as ScrollView).scrollTo({y: offset, animated: true}); } else { diff --git a/app/components/navigation_header/search_context.tsx b/app/components/navigation_header/search_context.tsx index 57145f196a..66063d210c 100644 --- a/app/components/navigation_header/search_context.tsx +++ b/app/components/navigation_header/search_context.tsx @@ -10,7 +10,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; type Props = { defaultHeight: number; largeHeight: number; - scrollValue: Animated.SharedValue; + scrollValue?: Animated.SharedValue; theme: Theme; } @@ -32,7 +32,7 @@ const NavigationHeaderSearchContext = ({ const styles = getStyleSheet(theme); const marginTop = useAnimatedStyle(() => { - return {marginTop: (-scrollValue.value + largeHeight + defaultHeight) - ANDROID_HEADER_SEARCH_INSET}; + return {marginTop: (-(scrollValue?.value || 0) + largeHeight + defaultHeight) - ANDROID_HEADER_SEARCH_INSET}; }, [defaultHeight, largeHeight]); return ( diff --git a/app/components/post_list/combined_user_activity/combined_user_activity.tsx b/app/components/post_list/combined_user_activity/combined_user_activity.tsx index 8372accf98..7480eea7b1 100644 --- a/app/components/post_list/combined_user_activity/combined_user_activity.tsx +++ b/app/components/post_list/combined_user_activity/combined_user_activity.tsx @@ -46,11 +46,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, container: { flexDirection: 'row', + paddingHorizontal: 20, + marginTop: 10, }, content: { flex: 1, flexDirection: 'column', - marginRight: 12, + marginLeft: 12, }, }; }); @@ -83,7 +85,7 @@ const CombinedUserActivity = ({ const you = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'}); const usernames = userIds.reduce((acc: string[], id: string) => { if (id !== currentUserId && id !== currentUsername) { - const name = usernamesById[id]; + const name = usernamesById[id] ?? Object.values(usernamesById).find((n) => n === id); acc.push(name ? `@${name}` : someone); } return acc; diff --git a/app/components/post_list/config.ts b/app/components/post_list/config.ts new file mode 100644 index 0000000000..88ec466333 --- /dev/null +++ b/app/components/post_list/config.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {INDICATOR_BAR_HEIGHT} from '@constants/view'; + +const HIDDEN_TOP = -400; +const MAX_INPUT = 1; +const MIN_INPUT = 0; +const SHOWN_TOP = 0; +const INDICATOR_BAR_FACTOR = Math.abs(INDICATOR_BAR_HEIGHT / (HIDDEN_TOP - SHOWN_TOP)); + +export const INITIAL_BATCH_TO_RENDER = 10; +export const SCROLL_POSITION_CONFIG = { + + // To avoid scrolling the list when new messages arrives + // if the user is not at the bottom + minIndexForVisible: 0, + + // If the user is at the bottom or 60px from the bottom + // auto scroll show the new message + autoscrollToTopThreshold: 60, +}; +export const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 50, + minimumViewTime: 100, +}; + +export const MORE_MESSAGES = { + CANCEL_TIMER_DELAY: 400, + HIDDEN_TOP, + INDICATOR_BAR_FACTOR, + MAX_INPUT, + MIN_INPUT, + SHOWN_TOP, + TOP_INTERPOL_CONFIG: { + inputRange: [ + MIN_INPUT, + MIN_INPUT + INDICATOR_BAR_FACTOR, + MAX_INPUT - INDICATOR_BAR_FACTOR, + MAX_INPUT, + ], + outputRange: [ + HIDDEN_TOP - INDICATOR_BAR_HEIGHT, + HIDDEN_TOP, + SHOWN_TOP, + SHOWN_TOP + INDICATOR_BAR_HEIGHT, + ], + extrapolate: 'clamp', + }, +}; diff --git a/app/components/post_list/date_separator/index.tsx b/app/components/post_list/date_separator/index.tsx index 73916fde2f..41e31855b6 100644 --- a/app/components/post_list/date_separator/index.tsx +++ b/app/components/post_list/date_separator/index.tsx @@ -7,6 +7,7 @@ import {StyleProp, View, ViewStyle} from 'react-native'; import FormattedDate from '@components/formatted_date'; import FormattedText from '@components/formatted_text'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; type DateSeparatorProps = { date: number | Date; @@ -21,6 +22,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { alignItems: 'center', flexDirection: 'row', marginVertical: 8, + paddingHorizontal: 20, }, line: { flex: 1, @@ -30,9 +32,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, date: { color: theme.centerChannelColor, - fontFamily: 'OpenSans-Semibold', - fontSize: 12, - lineHeight: 16, + marginHorizontal: 4, + ...typography('Body', 75, 'SemiBold'), }, }; }); @@ -41,6 +42,10 @@ export function isSameDay(a: Date, b: Date) { return a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear(); } +export function isSameYear(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear(); +} + export function isToday(date: Date) { const now = new Date(); @@ -76,9 +81,12 @@ const RecentDate = (props: DateSeparatorProps) => { ); } + const format = isSameYear(when, new Date()) ? 'MMM DD' : 'MMM DD, YYYY'; + return ( ); diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index a5f50cedf2..848ada82c9 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -1,76 +1,343 @@ // 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 {AppStateStatus} from 'react-native'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {FlatList} from '@stream-io/flat-list-mvcp'; +import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native'; +import Animated from 'react-native-reanimated'; -import {Preferences} from '@constants'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; -import {getPreferenceAsBool} from '@helpers/api/preference'; -import {getTimezone} from '@utils/user'; +import {fetchPosts} from '@actions/remote/post'; +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 {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list'; -import PostList from './post_list'; +import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config'; +import MoreMessages from './more_messages'; +import PostListRefreshControl from './refresh_control'; -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'; -import type PostsInChannelModel from '@typings/database/models/servers/posts_in_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: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES; +type Props = { + channelId: string; + contentContainerStyle?: StyleProp; + currentTimezone: string | null; + currentUsername: string; + highlightPinnedOrSaved?: boolean; + isTimezoneEnabled: boolean; + lastViewedAt: number; + location: string; + nativeID: string; + onEndReached?: () => void; + posts: PostModel[]; + rootId?: string; + shouldRenderReplyButton?: boolean; + shouldShowJoinLeaveMessages: boolean; + showMoreMessages?: boolean; + showNewMessageLine?: boolean; + footer?: ReactElement; + testID: string; +} -export const VIEWABILITY_CONFIG = { - itemVisiblePercentThreshold: 1, - minimumViewTime: 100, +type ViewableItemsChanged = { + viewableItems: ViewToken[]; + changed: ViewToken[]; +} + +type onScrollEndIndexListenerEvent = (endIndex: number) => void; +type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void; + +type ScrollIndexFailed = { + index: number; + highestMeasuredFrameIndex: number; + averageItemLength: number; }; -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)), - ); +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); +const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id); - return { - currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), - currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))), - isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( - switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), - ), - lastViewedAt: database.get(MY_CHANNEL).findAndObserve(channelId).pipe( - switchMap((myChannel) => of$(myChannel.viewedAt)), - ), - posts: database.get(POSTS_IN_CHANNEL).query( - Q.where('channel_id', channelId), - Q.sortBy('latest', Q.desc), - ).observeWithColumns(['earliest', 'latest']).pipe( - switchMap((postsInChannel) => { - if (!postsInChannel.length) { - return of$([]); - } - - const {earliest, latest} = postsInChannel[0]; - return database.get(POST).query( - Q.and( - Q.where('delete_at', 0), - Q.where('channel_id', channelId), - Q.where('create_at', Q.between(earliest, latest)), - ), - Q.sortBy('create_at', Q.desc), - ).observe(); - }), - ), - shouldShowJoinLeaveMessages: database.get(PREFERENCE).query( - Q.where('category', Preferences.CATEGORY_ADVANCED_SETTINGS), - Q.where('name', Preferences.ADVANCED_FILTER_JOIN_LEAVE), - ).observe().pipe( - switchMap((preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))), - ), - }; +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + container: { + flex: 1, + scaleY: -1, + }, + scale: { + ...Platform.select({ + android: { + scaleY: -1, + }, + }), + }, }); -export default withDatabase(enhanced(PostList)); +const PostList = ({ + channelId, + contentContainerStyle, + currentTimezone, + currentUsername, + footer, + highlightPinnedOrSaved = true, + isTimezoneEnabled, + lastViewedAt, + location, + nativeID, + onEndReached, + posts, + rootId, + shouldRenderReplyButton = true, + shouldShowJoinLeaveMessages, + showMoreMessages, + showNewMessageLine = true, + testID, +}: Props) => { + const listRef = useRef(null); + const onScrollEndIndexListener = useRef(); + const onViewableItemsChangedListener = useRef(); + const [offsetY, setOffsetY] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const orderedPosts = useMemo(() => { + return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, location === Screens.THREAD); + }, [posts, lastViewedAt, showNewMessageLine, currentTimezone, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, location]); + + const initialIndex = useMemo(() => { + return orderedPosts.indexOf(START_OF_NEW_MESSAGES); + }, [orderedPosts]); + + useEffect(() => { + listRef.current?.scrollToOffset({offset: 0, animated: false}); + }, [channelId, listRef.current]); + + useEffect(() => { + const scrollToBottom = (screen: string) => { + if (screen === location) { + const scrollToBottomTimer = setTimeout(() => { + listRef.current?.scrollToOffset({offset: 0, animated: true}); + clearTimeout(scrollToBottomTimer); + }, 400); + } + }; + + const scrollBottomListener = DeviceEventEmitter.addListener('scroll-to-bottom', scrollToBottom); + + return () => { + scrollBottomListener.remove(); + }; + }, []); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + if (location === Screens.CHANNEL && channelId) { + await fetchPosts(serverUrl, channelId); + } else if (location === Screens.THREAD && rootId) { + // await getPostThread(rootId); + } + setRefreshing(false); + }, [channelId, location, rootId]); + + const onScroll = useCallback((event: NativeSyntheticEvent) => { + if (Platform.OS === 'android') { + const {y} = event.nativeEvent.contentOffset; + if (y === 0) { + setOffsetY(y); + } else if (offsetY === 0 && y !== 0) { + setOffsetY(y); + } + } + }, [offsetY]); + + const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { + const index = Math.min(info.highestMeasuredFrameIndex, info.index); + if (onScrollEndIndexListener.current) { + onScrollEndIndexListener.current(index); + } + scrollToIndex(index); + }, []); + + 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); + + if (onViewableItemsChangedListener.current) { + onViewableItemsChangedListener.current(viewableItems); + } + }, []); + + const registerScrollEndIndexListener = useCallback((listener) => { + onScrollEndIndexListener.current = listener; + const removeListener = () => { + onScrollEndIndexListener.current = undefined; + }; + + return removeListener; + }, []); + + const registerViewableItemsListener = useCallback((listener) => { + onViewableItemsChangedListener.current = listener; + const removeListener = () => { + onViewableItemsChangedListener.current = undefined; + }; + + return removeListener; + }, []); + + 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' ? styles.scale : styles.container, + testID: `${testID}.combined_user_activity`, + showJoinLeave: shouldShowJoinLeaveMessages, + theme, + }; + + return (); + } + } + + let previousPost: PostModel|undefined; + let nextPost: PostModel|undefined; + 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 = { + highlightPinnedOrSaved, + location, + nextPost, + previousPost, + shouldRenderReplyButton, + }; + + return ( + + ); + }, [currentTimezone, highlightPinnedOrSaved, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]); + + const scrollToIndex = useCallback((index: number, animated = true) => { + listRef.current?.scrollToIndex({ + animated, + index, + viewOffset: 0, + viewPosition: 1, // 0 is at bottom + }); + }, []); + + return ( + <> + + + + {showMoreMessages && + + } + + ); +}; + +export default PostList; diff --git a/app/components/post_list/more_messages/index.ts b/app/components/post_list/more_messages/index.ts new file mode 100644 index 0000000000..478014d5af --- /dev/null +++ b/app/components/post_list/more_messages/index.ts @@ -0,0 +1,30 @@ +// 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 {switchMap} from 'rxjs/operators'; + +import {Database} from '@constants'; + +import MoreMessages from './more_messages'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; + +const {MM_TABLES} = Database; +const {SERVER: {MY_CHANNEL}} = MM_TABLES; + +const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => { + const myChannel = database.get(MY_CHANNEL).findAndObserve(channelId); + const isManualUnread = myChannel.pipe(switchMap((ch) => of$(ch.manuallyUnread))); + const unreadCount = myChannel.pipe(switchMap((ch) => of$(ch.messageCount))); + + return { + isManualUnread, + unreadCount, + }; +}); + +export default withDatabase(enhanced(MoreMessages)); diff --git a/app/components/post_list/more_messages/more_messages.tsx b/app/components/post_list/more_messages/more_messages.tsx new file mode 100644 index 0000000000..14cbf4b268 --- /dev/null +++ b/app/components/post_list/more_messages/more_messages.tsx @@ -0,0 +1,269 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {ActivityIndicator, DeviceEventEmitter, View, ViewToken} from 'react-native'; +import Animated, {interpolate, useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'; + +import {resetMessageCount} from '@actions/local/channel'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Events} from '@constants'; +import {useServerUrl} from '@context/server'; +import {makeStyleSheetFromTheme, hexToHue} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + channelId: string; + isManualUnread: boolean; + newMessageLineIndex: number; + posts: Array; + registerScrollEndIndexListener: (fn: (endIndex: number) => void) => () => void; + registerViewableItemsListener: (fn: (viewableItems: ViewToken[]) => void) => () => void; + scrollToIndex: (index: number, animated?: boolean) => void; + unreadCount: number; + theme: Theme; + testID: string; +} + +const HIDDEN_TOP = -60; +const SHOWN_TOP = 0; +const MIN_INPUT = 0; +const MAX_INPUT = 1; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + animatedContainer: { + position: 'absolute', + margin: 8, + backgroundColor: theme.buttonBg, + }, + cancelContainer: { + alignItems: 'center', + width: 32, + height: '100%', + justifyContent: 'center', + }, + container: { + flexDirection: 'row', + justifyContent: 'space-evenly', + alignItems: 'center', + paddingLeft: 12, + width: '100%', + height: 42, + shadowColor: theme.centerChannelColor, + shadowOffset: { + width: 0, + height: 6, + }, + shadowOpacity: 0.12, + shadowRadius: 4, + }, + roundBorder: { + borderRadius: 8, + }, + icon: { + fontSize: 18, + color: theme.buttonColor, + alignSelf: 'center', + }, + iconContainer: { + top: 2, + width: 22, + }, + pressContainer: { + flex: 1, + flexDirection: 'row', + }, + textContainer: { + paddingLeft: 4, + }, + text: { + color: theme.buttonColor, + ...typography('Body', 200, 'SemiBold'), + }, + }; +}); + +const MoreMessages = ({ + channelId, + isManualUnread, + newMessageLineIndex, + posts, + registerViewableItemsListener, + registerScrollEndIndexListener, + scrollToIndex, + unreadCount, + testID, + theme, +}: Props) => { + const serverUrl = useServerUrl(); + const pressed = useRef(false); + const resetting = useRef(false); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(0); + const underlayColor = useMemo(() => `hsl(${hexToHue(theme.buttonBg)}, 50%, 38%)`, [theme]); + const top = useSharedValue(0); + const BARS_FACTOR = Math.abs((1) / (HIDDEN_TOP - SHOWN_TOP)); + const styles = getStyleSheet(theme); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ + translateY: withSpring(interpolate( + top.value, + [ + MIN_INPUT, + MIN_INPUT + BARS_FACTOR, + MAX_INPUT - BARS_FACTOR, + MAX_INPUT, + ], + [ + HIDDEN_TOP, + HIDDEN_TOP, + SHOWN_TOP, + SHOWN_TOP, + ], + Animated.Extrapolate.CLAMP, + ), {damping: 15}), + }], + }), []); + + const resetCount = async () => { + if (resetting.current) { + return; + } + + resetting.current = true; + await resetMessageCount(serverUrl, channelId); + resetting.current = false; + }; + + const onViewableItemsChanged = (viewableItems: ViewToken[]) => { + pressed.current = false; + + if (newMessageLineIndex <= 0 || viewableItems.length === 0 || isManualUnread || resetting.current) { + return; + } + + const lastViewableIndex = viewableItems.filter((v) => v.isViewable)[viewableItems.length - 1]?.index || 0; + const nextViewableIndex = lastViewableIndex + 1; + if (viewableItems[0].index === 0 && nextViewableIndex > newMessageLineIndex) { + // Auto scroll if the first post is viewable and + // * the new message line is viewable OR + // * the new message line will be the first next viewable item + scrollToIndex(newMessageLineIndex, true); + resetCount(); + top.value = 0; + return; + } + + const readCount = posts.slice(0, lastViewableIndex).filter((v) => typeof v !== 'string').length; + const totalUnread = unreadCount - readCount; + if (lastViewableIndex >= newMessageLineIndex) { + resetCount(); + top.value = 0; + } else if (totalUnread > 0) { + setRemaining(totalUnread); + top.value = 1; + } + }; + + const onScrollEndIndex = () => { + pressed.current = false; + }; + + const onCancel = useCallback(() => { + pressed.current = true; + top.value = 0; + resetMessageCount(serverUrl, channelId); + pressed.current = false; + }, [serverUrl, channelId]); + + const onPress = useCallback(() => { + if (pressed.current) { + return; + } + + pressed.current = true; + scrollToIndex(newMessageLineIndex, true); + }, [newMessageLineIndex]); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.LOADING_CHANNEL_POSTS, (value: boolean) => { + setLoading(value); + }); + + return () => listener.remove(); + }, []); + + useEffect(() => { + const unregister = registerScrollEndIndexListener(onScrollEndIndex); + + return () => unregister(); + }, []); + + useEffect(() => { + const unregister = registerViewableItemsListener(onViewableItemsChanged); + + return () => unregister(); + }, [channelId, unreadCount, newMessageLineIndex, posts]); + + useEffect(() => { + resetting.current = false; + }, [channelId]); + + return ( + + + + <> + + {loading && + + } + {!loading && + + } + + + + + + + + + + + + + + ); +}; + +export default MoreMessages; diff --git a/app/components/post_list/new_message_line/index.tsx b/app/components/post_list/new_message_line/index.tsx index 628cc0fe1b..0912d88fa9 100644 --- a/app/components/post_list/new_message_line/index.tsx +++ b/app/components/post_list/new_message_line/index.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {StyleProp, View, ViewStyle} from 'react-native'; +import {typography} from '@app/utils/typography'; import FormattedText from '@components/formatted_text'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -14,6 +15,30 @@ type NewMessagesLineProps = { testID?: string; } +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + flexDirection: 'row', + height: 28, + paddingHorizontal: 20, + }, + textContainer: { + marginHorizontal: 15, + }, + line: { + flex: 1, + height: 1, + backgroundColor: theme.newMessageSeparator, + }, + text: { + color: theme.newMessageSeparator, + marginHorizontal: 4, + ...typography('Body', 75, 'SemiBold'), + }, + }; +}); + function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLineProps) { const styles = getStyleFromTheme(theme); @@ -48,27 +73,4 @@ function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLinePr ); } -const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { - return { - container: { - alignItems: 'center', - flexDirection: 'row', - height: 28, - }, - textContainer: { - marginHorizontal: 15, - }, - line: { - flex: 1, - height: 1, - backgroundColor: theme.newMessageSeparator, - }, - text: { - lineHeight: 16, - fontSize: 12, - color: theme.newMessageSeparator, - }, - }; -}); - export default NewMessagesLine; diff --git a/app/components/post_list/post/avatar/index.tsx b/app/components/post_list/post/avatar/index.tsx index bab093f922..f3fcd20e23 100644 --- a/app/components/post_list/post/avatar/index.tsx +++ b/app/components/post_list/post/avatar/index.tsx @@ -5,7 +5,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import React, {ReactNode, useRef} from 'react'; import {useIntl} from 'react-intl'; -import {Keyboard, Platform, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import {Keyboard, Platform, StyleSheet, View} from 'react-native'; import FastImage from 'react-native-fast-image'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; @@ -34,7 +34,6 @@ type AvatarProps = { enablePostIconOverride?: boolean; isAutoReponse: boolean; isSystemPost: boolean; - pendingPostStyle?: StyleProp; post: PostModel; } @@ -42,14 +41,9 @@ const style = StyleSheet.create({ buffer: { marginRight: Platform.select({android: 2, ios: 3}), }, - profilePictureContainer: { - marginBottom: 5, - marginRight: 10, - marginTop: 10, - }, }); -const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pendingPostStyle, post}: AvatarProps) => { +const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, post}: AvatarProps) => { const closeButton = useRef(); const intl = useIntl(); const theme = useTheme(); @@ -96,19 +90,17 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pe } return ( - - - {iconComponent} - + + {iconComponent} ); } @@ -157,11 +149,7 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pe ); } - return ( - - {component} - - ); + return component; }; const withPost = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => { diff --git a/app/components/post_list/post/body/files/index.tsx b/app/components/post_list/post/body/files/index.tsx index af63263fae..4ea49ba7ba 100644 --- a/app/components/post_list/post/body/files/index.tsx +++ b/app/components/post_list/post/body/files/index.tsx @@ -140,7 +140,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, theme={theme} isSingleImage={singleImage} nonVisibleImagesCount={nonVisibleImagesCount} - wrapperWidth={getViewPortWidth(isReplyPost, isTablet)} + wrapperWidth={getViewPortWidth(isReplyPost, isTablet) - 15} inViewPort={inViewPort} /> @@ -154,7 +154,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, } const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES); - const portraitPostWidth = getViewPortWidth(isReplyPost, isTablet); + const portraitPostWidth = getViewPortWidth(isReplyPost, isTablet) - 15; let nonVisibleImagesCount; if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) { diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index 4450eea83b..257fb8f74e 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -41,8 +41,7 @@ type BodyProps = { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { messageBody: { - paddingBottom: 2, - paddingTop: 2, + paddingVertical: 2, flex: 1, }, messageContainer: {width: '100%'}, diff --git a/app/components/post_list/post/header/display_name/index.tsx b/app/components/post_list/post/header/display_name/index.tsx index 0a83f7dbba..76a2747ec0 100644 --- a/app/components/post_list/post/header/display_name/index.tsx +++ b/app/components/post_list/post/header/display_name/index.tsx @@ -11,6 +11,7 @@ import TouchableWithFeedback from '@components/touchable_with_feedback'; import {showModal} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import type {ImageSource} from 'react-native-vector-icons/Icon'; @@ -28,10 +29,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { displayName: { color: theme.centerChannelColor, - fontFamily: 'OpenSans-Semibold', - fontSize: 16, - lineHeight: 24, flexGrow: 1, + ...typography('Body', 200, 'SemiBold'), }, displayNameContainer: { maxWidth: '60%', diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index cbb35477c1..cf17f78695 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {View} from 'react-native'; +import {typography} from '@app/utils/typography'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import FormattedTime from '@components/formatted_time'; import {CHANNEL, THREAD} from '@constants/screens'; @@ -55,11 +56,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, time: { color: theme.centerChannelColor, - fontSize: 12, - lineHeight: 16, marginTop: 5, opacity: 0.5, flex: 1, + ...typography('Body', 75, 'Regular'), }, customStatusEmoji: { color: theme.centerChannelColor, diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 129675a470..8640250329 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -70,6 +70,17 @@ async function shouldHighlightReplyBar(currentUser: UserModel, post: PostModel, return false; } + +function isFirstReply(post: PostModel, previousPost?: PostModel) { + if (post.rootId) { + if (previousPost) { + return post.rootId !== previousPost.id && post.rootId !== previousPost.rootId; + } + return true; + } + return false; +} + const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ featureFlagAppsEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled)), @@ -82,17 +93,12 @@ const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ const withPost = withObservables( ['currentUser', 'post', 'previousPost', 'nextPost'], ({featureFlagAppsEnabled, currentUser, database, post, previousPost, nextPost}: PropsInput) => { - let isFirstReply = of$(true); let isJumboEmoji = of$(false); let isLastReply = of$(true); let isPostAddChannelMember = of$(false); const isOwner = currentUser.id === post.userId; + const author = post.author.observe(); const canDelete = from$(hasPermissionForPost(post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false)); - const isConsecutivePost = post.author.observe().pipe(switchMap( - (user: UserModel) => { - return of$(Boolean(post && previousPost && !user.isBot && post.rootId && areConsecutivePosts(post, previousPost))); - }, - )); const isEphemeral = of$(isPostEphemeral(post)); const isFlagged = database.get(PREFERENCE).query( Q.where('category', Preferences.CATEGORY_FLAGGED_POST), @@ -114,7 +120,6 @@ const withPost = withObservables( let differentThreadSequence = true; if (post.rootId) { differentThreadSequence = previousPost?.rootId ? previousPost?.rootId !== post.rootId : previousPost?.id !== post.rootId; - isFirstReply = of$(differentThreadSequence || (previousPost?.id === post.rootId || previousPost?.rootId === post.rootId)); isLastReply = of$(!(nextPost?.rootId === post.rootId)); } @@ -125,6 +130,10 @@ const withPost = withObservables( ), ); } + const hasReplies = from$(post.hasReplies()); + const isConsecutivePost = author.pipe( + switchMap((user) => of$(Boolean(post && previousPost && !user.isBot && areConsecutivePosts(post, previousPost)))), + ); const partialConfig: Partial = { FeatureFlagAppsEnabled: featureFlagAppsEnabled, @@ -133,13 +142,13 @@ const withPost = withObservables( return { appsEnabled: of$(appsEnabled(partialConfig)), canDelete, - currentUser, differentThreadSequence: of$(differentThreadSequence), files: post.files.observe(), + hasReplies, highlightReplyBar, isConsecutivePost, isEphemeral, - isFirstReply, + isFirstReply: of$(isFirstReply(post, previousPost)), isFlagged, isJumboEmoji, isLastReply, diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index b8ef7c613f..9f10259a73 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useRef} from 'react'; +import React, {ReactNode, useMemo, useRef} from 'react'; import {useIntl} from 'react-intl'; -import {DeviceEventEmitter, Keyboard, StyleProp, View, ViewStyle} from 'react-native'; +import {DeviceEventEmitter, Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'; import {showPermalink} from '@actions/local/permalink'; import {removePost} from '@actions/local/post'; @@ -34,8 +34,9 @@ type PostProps = { currentUser: UserModel; differentThreadSequence: boolean; files: FileModel[]; + hasReplies: boolean; highlight?: boolean; - highlightPinnedOrFlagged?: boolean; + highlightPinnedOrSaved?: boolean; highlightReplyBar: boolean; isConsecutivePost?: boolean; isEphemeral: boolean; @@ -46,6 +47,7 @@ type PostProps = { isPostAddChannelMember: boolean; location: string; post: PostModel; + previousPost?: PostModel; reactionsCount: number; shouldRenderReplyButton?: boolean; showAddReaction?: boolean; @@ -61,7 +63,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { consecutivePostContainer: { marginBottom: 10, marginRight: 10, - marginLeft: 27, + marginLeft: Platform.select({ios: 35, android: 34}), marginTop: 10, }, container: {flexDirection: 'row'}, @@ -70,27 +72,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { backgroundColor: theme.mentionHighlightBg, opacity: 1, }, - highlightPinnedOrFlagged: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2)}, + highlightPinnedOrSaved: { + backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2), + }, pendingPost: {opacity: 0.5}, postStyle: { overflow: 'hidden', flex: 1, + paddingHorizontal: 20, }, profilePictureContainer: { marginBottom: 5, marginRight: 10, marginTop: 10, }, - replyBar: { - backgroundColor: theme.centerChannelColor, - opacity: 0.1, - marginLeft: 1, - marginRight: 7, - width: 3, - flexBasis: 3, - }, - replyBarFirst: {paddingTop: 10}, - replyBarLast: {paddingBottom: 10}, rightColumn: { flex: 1, flexDirection: 'column', @@ -100,10 +95,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Post = ({ - appsEnabled, canDelete, currentUser, differentThreadSequence, files, highlight, highlightPinnedOrFlagged = true, highlightReplyBar, + appsEnabled, canDelete, currentUser, differentThreadSequence, files, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, isConsecutivePost, isEphemeral, isFirstReply, isFlagged, isJumboEmoji, isLastReply, isPostAddChannelMember, location, post, reactionsCount, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, style, - testID, + testID, previousPost, }: PostProps) => { const pressDetected = useRef(false); const intl = useIntl(); @@ -114,6 +109,17 @@ const Post = ({ const isPendingOrFailed = isPostPendingOrFailed(post); const isSystemPost = isSystemMessage(post); const isWebHook = isFromWebhook(post); + const hasSameRoot = useMemo(() => { + if (isFirstReply) { + return false; + } else if (!post.rootId && !previousPost?.rootId && isConsecutivePost) { + return true; + } else if (post.rootId) { + return true; + } + + return false; + }, [isConsecutivePost, post, previousPost, isFirstReply]); const handlePress = preventDoubleTap(() => { pressDetected.current = true; @@ -179,14 +185,15 @@ const Post = ({ let highlightedStyle: StyleProp; if (highlight) { highlightedStyle = styles.highlight; - } else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) { - highlightedStyle = styles.highlightPinnedOrFlagged; + } else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrSaved) { + highlightedStyle = styles.highlightPinnedOrSaved; } let header: ReactNode; let postAvatar: ReactNode; let consecutiveStyle: StyleProp; - if (isConsecutivePost) { + const sameSecuence = hasReplies ? (hasReplies && post.rootId) : !post.rootId; + if (hasSameRoot && isConsecutivePost && sameSecuence) { consecutiveStyle = styles.consective; postAvatar = ; } else { diff --git a/app/components/post_list/post/pre_header/index.tsx b/app/components/post_list/post/pre_header/index.tsx index 540f573e28..53a7242739 100644 --- a/app/components/post_list/post/pre_header/index.tsx +++ b/app/components/post_list/post/pre_header/index.tsx @@ -24,7 +24,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flex: 1, flexDirection: 'row', height: 15, - marginLeft: 10, marginRight: 10, marginTop: 10, }, @@ -89,7 +88,7 @@ const PreHeader = ({isConsecutivePost, isFlagged, isPinned, skipFlaggedHeader, s {isPinned && !skipPinnedHeader && @@ -99,7 +98,7 @@ const PreHeader = ({isConsecutivePost, isFlagged, isPinned, skipFlaggedHeader, s } {isFlagged && !skipFlaggedHeader && 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 6ce76c56e7..ad8d5b5e2b 100644 --- a/app/components/post_list/post/system_message/system_message.tsx +++ b/app/components/post_list/post/system_message/system_message.tsx @@ -257,14 +257,25 @@ const systemMessageRenderers = { export const SystemMessage = ({post, author}: SystemMessageProps) => { const intl = useIntl(); const theme = useTheme(); - const renderer = systemMessageRenderers[post.type]; - if (!renderer) { - return null; - } const style = getStyleSheet(theme); const textStyles = getMarkdownTextStyles(theme); const styles = {messageStyle: style.systemMessage, textStyles}; + + const renderer = systemMessageRenderers[post.type]; + if (!renderer) { + return ( + + ); + return null; + } + return renderer({post, author, styles, intl, theme}); }; -export default React.memo(SystemMessage); +export default SystemMessage; diff --git a/app/components/post_list/refresh_control.tsx b/app/components/post_list/refresh_control.tsx new file mode 100644 index 0000000000..8f54d63490 --- /dev/null +++ b/app/components/post_list/refresh_control.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, RefreshControl, StyleProp, ViewStyle} from 'react-native'; + +type Props = { + children: React.ReactElement; + enabled: boolean; + onRefresh: () => void; + refreshing: boolean; + style?: StyleProp; +} + +const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, style}: Props) => { + const props = { + onRefresh, + refreshing, + }; + + if (Platform.OS === 'android') { + return ( + + {children} + + ); + } + + const refreshControl = ; + + return React.cloneElement( + children, + {refreshControl, inverted: true}, + ); +}; + +export default PostListRefreshControl; diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx index 0db8bcd49a..2a764d7021 100644 --- a/app/components/search/index.tsx +++ b/app/components/search/index.tsx @@ -4,7 +4,7 @@ /* eslint-disable react/prop-types */ // We disable the prop types check here as forwardRef & typescript has a bug -import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {ActivityIndicatorProps, Platform, StyleProp, TextInput, TextInputProps, TextStyle, TouchableOpacityProps, ViewStyle} from 'react-native'; import {SearchBar} from 'react-native-elements'; @@ -73,7 +73,7 @@ const Search = forwardRef((props: SearchProps, ref) => { const theme = useTheme(); const styles = getStyleSheet(theme); const searchRef = useRef(null); - const [value, setValue] = useState(props.value || ''); + const [value, setValue] = useState(props.defaultValue || props.value || ''); const searchClearButtonTestID = `${props.testID}.search.clear.button`; const searchCancelButtonTestID = `${props.testID}.search.cancel.button`; const searchInputTestID = `${props.testID}.search.input`; @@ -100,6 +100,10 @@ const Search = forwardRef((props: SearchProps, ref) => { }, }), [theme]); + useEffect(() => { + setValue(props.defaultValue || value || ''); + }, [props.defaultValue]); + const clearIcon = useMemo(() => { return ( { return ( - - - + ); }; diff --git a/app/components/system_header/index.tsx b/app/components/system_header/index.tsx index 6176c43a08..70811daa58 100644 --- a/app/components/system_header/index.tsx +++ b/app/components/system_header/index.tsx @@ -14,6 +14,7 @@ import {Preferences} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {getPreferenceAsBool} from '@helpers/api/preference'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import {getUserTimezone} from '@utils/user'; import type {WithDatabaseArgs} from '@typings/database/database'; @@ -35,15 +36,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { displayName: { color: theme.centerChannelColor, - fontSize: 15, - fontFamily: 'OpenSans-Semibold', flexGrow: 1, - paddingVertical: 2, + ...typography('Body', 200, 'SemiBold'), }, displayNameContainer: { maxWidth: '60%', marginRight: 5, - marginBottom: 3, }, header: { flex: 1, @@ -52,10 +50,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, time: { color: theme.centerChannelColor, - fontSize: 12, marginTop: 5, opacity: 0.5, flex: 1, + ...typography('Body', 75, 'Regular'), }, }; }); @@ -105,4 +103,4 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { }; }); -export default withDatabase(enhanced(React.memo(SystemHeader))); +export default withDatabase(enhanced(SystemHeader)); diff --git a/app/components/team_sidebar/team_list/index.ts b/app/components/team_sidebar/team_list/index.ts index e4797ccc7b..212e93f7f2 100644 --- a/app/components/team_sidebar/team_list/index.ts +++ b/app/components/team_sidebar/team_list/index.ts @@ -54,7 +54,7 @@ const withTeams = withObservables([], ({database}: WithDatabaseArgs) => { return ts.sort((a, b) => { if ((indexes[a.id] != null) || (indexes[b.id] != null)) { - return (indexes[a.id] ?? -1) - (indexes[b.id] ?? -1); + return (indexes[a.id] ?? tids.length) - (indexes[b.id] ?? tids.length); } return (originalIndexes[a.id] - originalIndexes[b.id]); }); diff --git a/app/components/team_sidebar/team_list/team_item/team_item.tsx b/app/components/team_sidebar/team_list/team_item/team_item.tsx index 6100d69c7c..5c374321e7 100644 --- a/app/components/team_sidebar/team_list/team_item/team_item.tsx +++ b/app/components/team_sidebar/team_list/team_item/team_item.tsx @@ -4,7 +4,7 @@ import React from 'react'; import {View} from 'react-native'; -import {handleTeamChange} from '@actions/local/team'; +import {handleTeamChange} from '@actions/remote/team'; import Badge from '@components/badge'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useServerUrl} from '@context/server'; @@ -22,6 +22,41 @@ type Props = { currentTeamId: string; } +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + height: 54, + width: 54, + flex: 0, + padding: 3, + borderRadius: 10, + marginVertical: 3, + overflow: 'hidden', + }, + containerSelected: { + borderWidth: 3, + borderRadius: 12, + borderColor: theme.sidebarTextActiveBorder, + }, + unread: { + left: 40, + top: 3, + }, + mentionsOneDigit: { + top: 1, + left: 28, + }, + mentionsTwoDigits: { + top: 1, + left: 26, + }, + mentionsThreeDigits: { + top: 1, + left: 23, + }, + }; +}); + export default function TeamItem({team, hasUnreads, mentionCount, currentTeamId}: Props) { const theme = useTheme(); const styles = getStyleSheet(theme); @@ -64,45 +99,10 @@ export default function TeamItem({team, hasUnreads, mentionCount, currentTeamId} ); } - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - container: { - height: 54, - width: 54, - flex: 0, - padding: 3, - borderRadius: 10, - marginVertical: 3, - overflow: 'hidden', - }, - containerSelected: { - borderWidth: 3, - borderRadius: 12, - borderColor: theme.sidebarTextActiveBorder, - }, - unread: { - left: 40, - top: 3, - }, - mentionsOneDigit: { - top: 1, - left: 28, - }, - mentionsTwoDigits: { - top: 1, - left: 26, - }, - mentionsThreeDigits: { - top: 1, - left: 23, - }, - }; -}); diff --git a/app/constants/events.ts b/app/constants/events.ts index fabed5d075..cd46ecc3c0 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -9,6 +9,7 @@ export default keyMirror({ CONFIG_CHANGED: null, LEAVE_CHANNEL: null, LEAVE_TEAM: null, + LOADING_CHANNEL_POSTS: null, NOTIFICATION_ERROR: null, SERVER_LOGOUT: null, SERVER_VERSION_CHANGED: null, diff --git a/app/constants/navigation.ts b/app/constants/navigation.ts index e18d7463a6..9a23ce085b 100644 --- a/app/constants/navigation.ts +++ b/app/constants/navigation.ts @@ -4,6 +4,7 @@ import keyMirror from '@utils/key_mirror'; const Navigation = keyMirror({ + NAVIGATE_TO_TAB: null, NAVIGATION_CLOSE_MODAL: null, NAVIGATION_HOME: null, NAVIGATION_SHOW_OVERLAY: null, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 9ab8bcd066..aabd61bd2e 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. export const ABOUT = 'About'; +export const ACCOUNT = 'Account'; export const EMOJI_PICKER = 'AddReaction'; export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; @@ -17,7 +18,7 @@ export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const IN_APP_NOTIFICATION = 'InAppNotification'; export const LOGIN = 'Login'; -export const MAIN_SIDEBAR = 'MainSidebar'; +export const MENTIONS = 'Mentions'; export const MFA = 'MFA'; export const PERMALINK = 'Permalink'; export const SEARCH = 'Search'; @@ -26,10 +27,10 @@ export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const SSO = 'SSO'; export const THREAD = 'Thread'; export const USER_PROFILE = 'UserProfile'; -export const MENTIONS = 'Mentions'; export default { ABOUT, + ACCOUNT, EMOJI_PICKER, APP_FORM, BOTTOM_SHEET, @@ -45,7 +46,7 @@ export default { INTEGRATION_SELECTOR, IN_APP_NOTIFICATION, LOGIN, - MAIN_SIDEBAR, + MENTIONS, MFA, PERMALINK, SEARCH, @@ -54,5 +55,4 @@ export default { SSO, THREAD, USER_PROFILE, - MENTIONS, }; diff --git a/app/constants/view.ts b/app/constants/view.ts index 2c4158434b..c2458686cd 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -7,9 +7,11 @@ export const BOTTOM_TAB_ICON_SIZE = 31.2; export const PROFILE_PICTURE_SIZE = 32; export const PROFILE_PICTURE_EMOJI_SIZE = 28; export const SEARCH_INPUT_HEIGHT = Platform.select({android: 40, ios: 36})!; -export const TABLET_SIDEBAR_WIDTH = 320; + export const TEAM_SIDEBAR_WIDTH = 72; export const TABLET_HEADER_HEIGHT = 44; +export const TABLET_SIDEBAR_WIDTH = 320; + export const IOS_DEFAULT_HEADER_HEIGHT = 50; export const ANDROID_DEFAULT_HEADER_HEIGHT = 56; export const LARGE_HEADER_TITLE = 60; @@ -19,6 +21,8 @@ export const IOS_HEADER_SEARCH_INSET = 20; export const TABLET_HEADER_SEARCH_INSET = 28; export const ANDROID_HEADER_SEARCH_INSET = 11; +export const INDICATOR_BAR_HEIGHT = 38; + export default { BOTTOM_TAB_ICON_SIZE, PROFILE_PICTURE_SIZE, @@ -37,4 +41,5 @@ export default { IOS_HEADER_SEARCH_INSET, TABLET_HEADER_SEARCH_INSET, ANDROID_HEADER_SEARCH_INSET, + INDICATOR_BAR_HEIGHT, }; diff --git a/app/database/models/server/post.ts b/app/database/models/server/post.ts index 600161d838..56e8b1e062 100644 --- a/app/database/models/server/post.ts +++ b/app/database/models/server/post.ts @@ -124,4 +124,17 @@ export default class PostModel extends Model { ).destroyAllPermanently(); super.destroyPermanently(); } + + async hasReplies() { + if (!this.rootId) { + return (await this.postsInThread.fetch()).length > 0; + } + + const root = await this.root.fetch(); + if (root.length) { + return (await root[0].postsInThread.fetch()).length > 0; + } + + return false; + } } diff --git a/app/database/operator/server_data_operator/handlers/posts_in_channel.ts b/app/database/operator/server_data_operator/handlers/posts_in_channel.ts index 7564ebb626..11acb478c3 100644 --- a/app/database/operator/server_data_operator/handlers/posts_in_channel.ts +++ b/app/database/operator/server_data_operator/handlers/posts_in_channel.ts @@ -167,7 +167,49 @@ const PostsInChannelHandler = (superclass: any) => class extends superclass { }; handleReceivedPostsInChannelBefore = async (posts: Post[], prepareRecordsOnly = false): Promise => { - throw new Error(`handleReceivedPostsInChannelBefore Not implemented yet. posts count${posts.length} prepareRecordsOnly=${prepareRecordsOnly}`); + if (!posts.length) { + return []; + } + + const {firstPost} = getPostListEdges(posts); + + // Channel Id for this chain of posts + const channelId = firstPost.channel_id; + + // Find smallest 'create_at' value in chain + const earliest = firstPost.create_at; + + // Find the records in the PostsInChannel table that have a matching channel_id + const chunks = (await this.database.get(POSTS_IN_CHANNEL).query( + Q.where('channel_id', channelId), + Q.sortBy('latest', Q.desc), + ).fetch()) as PostsInChannelModel[]; + + if (chunks.length === 0) { + // No chunks found, previous posts in this block not found + return []; + } + + let targetChunk = chunks[0]; + if (targetChunk) { + // If the chunk was found, Update the chunk and return + if (prepareRecordsOnly) { + targetChunk.prepareUpdate((record) => { + record.earliest = Math.min(record.earliest, earliest); + }); + return [targetChunk]; + } + + targetChunk = await this.database.write(async () => { + return targetChunk!.update((record) => { + record.earliest = Math.min(record.earliest, earliest); + }); + }); + + return [targetChunk!]; + } + + return targetChunk; }; handleReceivedPostsInChannelAfter = async (posts: Post[], prepareRecordsOnly = false): Promise => { diff --git a/app/hooks/header.ts b/app/hooks/header.ts index d6bc2258e9..9bd37b92cf 100644 --- a/app/hooks/header.ts +++ b/app/hooks/header.ts @@ -129,6 +129,8 @@ export const useCollapsibleHeader = (isLargeTitle: boolean, hasSubtitle: bool } return { + defaultHeight, + largeHeight, scrollPaddingTop: (isLargeTitle ? largeHeight : defaultHeight) + searchPadding, scrollRef: animatedRef as unknown as React.RefObject, scrollValue, diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx new file mode 100644 index 0000000000..6573452433 --- /dev/null +++ b/app/screens/channel/channel.tsx @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, Keyboard, Platform, View} from 'react-native'; +import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; + +import CompassIcon from '@components/compass_icon'; +import NavigationHeader from '@components/navigation_header'; +import {Navigation} from '@constants'; +import {useTheme} from '@context/theme'; +import {useAppState, useIsTablet} from '@hooks/device'; +import {useDefaultHeaderHeight} from '@hooks/header'; +import {popTopScreen} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import ChannelPostList from './channel_post_list'; +import FailedChannels from './failed_channels'; +import FailedTeams from './failed_teams'; +import OtherMentionsBadge from './other_mentions_badge'; + +import type {HeaderRightButton} from '@components/navigation_header/header'; + +type ChannelProps = { + channelId: string; + componentId?: string; + displayName: string; + isOwnDirectMessage: boolean; + memberCount: number; + name: string; + teamId: string; +}; + +const edges: Edge[] = ['left', 'right']; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + flex: { + flex: 1, + }, + sectionContainer: { + marginTop: 10, + paddingHorizontal: 24, + }, + sectionTitle: { + fontSize: 16, + fontFamily: 'OpenSans-Semibold', + color: theme.centerChannelColor, + }, +})); + +const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, memberCount, name, teamId}: ChannelProps) => { + const {formatMessage} = useIntl(); + const appState = useAppState(); + const isTablet = useIsTablet(); + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const defaultHeight = useDefaultHeaderHeight(); + const rightButtons: HeaderRightButton[] = useMemo(() => ([{ + iconName: 'magnify', + onPress: () => { + DeviceEventEmitter.emit(Navigation.NAVIGATE_TO_TAB, {screen: 'Search', params: {searchTerm: `in: ${name}`}}); + if (!isTablet) { + popTopScreen(componentId); + } + }, + }, { + iconName: Platform.select({android: 'dots-vertical', default: 'dots-horizontal'}), + onPress: () => true, + buttonType: 'opacity', + }]), [channelId, isTablet, name]); + + const leftComponent = useMemo(() => { + if (isTablet || !channelId || !teamId) { + return undefined; + } + + return (); + }, [isTablet, channelId, teamId]); + + const subtitleCompanion = useMemo(() => ( + + ), []); + + const onBackPress = useCallback(() => { + Keyboard.dismiss(); + popTopScreen(componentId); + }, []); + + const onTitlePress = useCallback(() => { + // eslint-disable-next-line no-console + console.log('Title Press go to Channel Info', displayName); + }, [channelId]); + + if (!teamId) { + return ; + } + + if (!channelId) { + return ; + } + + let title = displayName; + if (isOwnDirectMessage) { + title = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}); + } + + const marginTop = defaultHeight + (isTablet ? insets.top : 0); + + return ( + <> + + + + + + + + ); +}; + +export default Channel; diff --git a/app/screens/channel/channel_nav_bar/channel_title/channel_display_name/index.tsx b/app/screens/channel/channel_nav_bar/channel_title/channel_display_name/index.tsx deleted file mode 100644 index 36b1d05ebf..0000000000 --- a/app/screens/channel/channel_nav_bar/channel_title/channel_display_name/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Text} from 'react-native'; - -import FormattedText from '@components/formatted_text'; -import {General} from '@constants'; -import {makeStyleSheetFromTheme} from '@utils/theme'; - -type ChannelDisplayNameProps = { - channelType: string; - currentUserId: string; - displayName: string; - teammateId?: string; - theme: Theme; -}; - -const ChannelDisplayName = ({channelType, currentUserId, displayName, teammateId, theme}: ChannelDisplayNameProps) => { - const style = getStyle(theme); - let isSelfDMChannel = false; - if (channelType === General.DM_CHANNEL && teammateId) { - isSelfDMChannel = currentUserId === teammateId; - } - - return ( - - {isSelfDMChannel ? ( - ) : displayName - } - - ); -}; - -const getStyle = makeStyleSheetFromTheme((theme) => { - return { - text: { - color: theme.sidebarHeaderTextColor, - fontSize: 18, - fontFamily: 'OpenSans-Semibold', - textAlign: 'center', - flex: 0, - flexShrink: 1, - }, - }; -}); - -export default ChannelDisplayName; diff --git a/app/screens/channel/channel_nav_bar/channel_title/channel_guest_label/index.tsx b/app/screens/channel/channel_nav_bar/channel_title/channel_guest_label/index.tsx deleted file mode 100644 index e43656262a..0000000000 --- a/app/screens/channel/channel_nav_bar/channel_title/channel_guest_label/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {View} from 'react-native'; - -import FormattedText from '@components/formatted_text'; -import {General} from '@constants'; -import {t} from '@i18n'; -import {makeStyleSheetFromTheme} from '@utils/theme'; - -type ChannelGuestLabelProps = { - channelType: string; - theme: Theme; -} - -const ChannelGuestLabel = ({channelType, theme}: ChannelGuestLabelProps) => { - const style = getStyle(theme); - - let messageId; - let defaultMessage; - - switch (channelType) { - case General.DM_CHANNEL: { - messageId = t('channel.isGuest'); - defaultMessage = 'This person is a guest'; - break; - } - case General.GM_CHANNEL: { - messageId = t('channel.hasGuests'); - defaultMessage = 'This group message has guests'; - break; - } - default : { - messageId = t('channel.channelHasGuests'); - defaultMessage = 'This channel has guests'; - break; - } - } - - return ( - - - - ); -}; - -const getStyle = makeStyleSheetFromTheme((theme) => { - return { - guestsWrapper: { - alignItems: 'flex-start', - flex: 1, - position: 'relative', - top: -1, - width: '90%', - }, - guestsText: { - color: theme.sidebarHeaderTextColor, - fontSize: 14, - opacity: 0.6, - }, - }; -}); - -export default ChannelGuestLabel; diff --git a/app/screens/channel/channel_nav_bar/channel_title/index.tsx b/app/screens/channel/channel_nav_bar/channel_title/index.tsx deleted file mode 100644 index c79007f42f..0000000000 --- a/app/screens/channel/channel_nav_bar/channel_title/index.tsx +++ /dev/null @@ -1,202 +0,0 @@ -// 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 React from 'react'; -import {TouchableOpacity, View} from 'react-native'; -import {of as of$, Observable} from 'rxjs'; -import {map, switchMap} from 'rxjs/operators'; - -import ChannelIcon from '@components/channel_icon'; -import CompassIcon from '@components/compass_icon'; -import {General} from '@constants'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; -import {useTheme} from '@context/theme'; -import {makeStyleSheetFromTheme} from '@utils/theme'; -import {getUserIdFromChannelName, isGuest as isTeammateGuest} from '@utils/user'; - -import ChannelDisplayName from './channel_display_name'; -import ChannelGuestLabel from './channel_guest_label'; - -import type {WithDatabaseArgs} from '@typings/database/database'; -import type ChannelModel from '@typings/database/models/servers/channel'; -import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; -import type MyChannelSettingsModel from '@typings/database/models/servers/my_channel_settings'; -import type SystemModel from '@typings/database/models/servers/system'; -import type UserModel from '@typings/database/models/servers/user'; - -const {SERVER: {SYSTEM, USER}} = MM_TABLES; - -type WithChannelArgs = WithDatabaseArgs & { - channel: ChannelModel; -} - -type ChannelTitleProps = { - canHaveSubtitle: boolean; - channel: ChannelModel; - currentUserId: string; - channelInfo: ChannelInfoModel; - channelSettings: MyChannelSettingsModel; - onPress: () => void; - teammate?: UserModel; -}; - -const ChannelTitle = ({ - canHaveSubtitle, - channel, - channelInfo, - channelSettings, - currentUserId, - onPress, - teammate, -}: ChannelTitleProps) => { - const theme = useTheme(); - const style = getStyle(theme); - const channelType = channel.type; - const isArchived = channel.deleteAt !== 0; - const isChannelMuted = channelSettings.notifyProps?.mark_unread === 'mention'; - const isChannelShared = channel.shared; - const hasGuests = channelInfo.guestCount > 0; - const teammateRoles = teammate?.roles ?? ''; - const isGuest = channelType === General.DM_CHANNEL && isTeammateGuest(teammateRoles); - - const showGuestLabel = (canHaveSubtitle && ((isGuest && hasGuests) || (channelType === General.DM_CHANNEL && isGuest))); - - return ( - - - {isArchived && ( - - )} - - {isChannelShared && ( - - )} - - {isChannelMuted && ( - - )} - - {showGuestLabel && ( - - )} - - ); -}; - -const getStyle = makeStyleSheetFromTheme((theme) => { - return { - container: { - flex: 1, - }, - wrapper: { - alignItems: 'center', - flex: 1, - position: 'relative', - top: -1, - flexDirection: 'row', - justifyContent: 'flex-start', - width: '90%', - }, - icon: { - color: theme.sidebarHeaderTextColor, - marginHorizontal: 1, - }, - emoji: { - marginHorizontal: 5, - }, - text: { - color: theme.sidebarHeaderTextColor, - fontSize: 18, - fontFamily: 'OpenSans-Semibold', - textAlign: 'center', - flex: 0, - flexShrink: 1, - }, - channelIconContainer: { - marginLeft: 3, - marginRight: 0, - }, - muted: { - marginTop: 1, - opacity: 0.6, - marginLeft: 0, - }, - archiveIcon: { - fontSize: 17, - color: theme.sidebarHeaderTextColor, - paddingRight: 7, - }, - guestsWrapper: { - alignItems: 'flex-start', - flex: 1, - position: 'relative', - top: -1, - width: '90%', - }, - guestsText: { - color: theme.sidebarHeaderTextColor, - fontSize: 14, - opacity: 0.6, - }, - }; -}); - -const enhanced = withObservables(['channel'], ({channel, database}: WithChannelArgs) => { - const currentUserId = database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( - map(({value}: {value: string}) => value), - ); - let teammate: Observable = of$(undefined); - if (channel.type === General.DM_CHANNEL && channel.displayName) { - teammate = currentUserId.pipe( - switchMap((id) => { - const teammateId = getUserIdFromChannelName(id, channel.name); - if (teammateId) { - return database.get(USER).findAndObserve(teammateId); - } - return of$(undefined); - }), - ); - } - - return { - channelInfo: channel.info.observe(), - channelSettings: channel.settings.observe(), - currentUserId, - teammate, - }; -}); - -export default withDatabase(enhanced(ChannelTitle)); diff --git a/app/screens/channel/channel_nav_bar/index.tsx b/app/screens/channel/channel_nav_bar/index.tsx deleted file mode 100644 index 5643a16c25..0000000000 --- a/app/screens/channel/channel_nav_bar/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// 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 React from 'react'; -import {Platform} from 'react-native'; -import {SafeAreaView} from 'react-native-safe-area-context'; - -import {MM_TABLES} from '@constants/database'; -import {useTheme} from '@context/theme'; -import {makeStyleSheetFromTheme} from '@utils/theme'; - -import ChannelTitle from './channel_title'; - -import type {WithDatabaseArgs} from '@typings/database/database'; -import type ChannelModel from '@typings/database/models/servers/channel'; - -type ChannelNavBar = { - channel: ChannelModel; - onPress: () => void; -} - -// Todo: Create common NavBar: See Gekidou & Mobile v2 task Board -const ChannelNavBar = ({channel, onPress}: ChannelNavBar) => { - const theme = useTheme(); - const style = getStyleFromTheme(theme); - - return ( - - - - ); -}; - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return { - header: { - backgroundColor: theme.sidebarBg, - flexDirection: 'row', - justifyContent: 'flex-start', - width: '100%', - ...Platform.select({ - android: { - elevation: 10, - height: 56, - }, - ios: { - zIndex: 10, - height: 88, - }, - }), - }, - }; -}); - -const withChannel = withObservables(['channelId'], ({channelId, database}: {channelId: string } & WithDatabaseArgs) => ({ - channel: database.get(MM_TABLES.SERVER.CHANNEL).findAndObserve(channelId), -})); - -export default withDatabase(withChannel(ChannelNavBar)); diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx new file mode 100644 index 0000000000..d3f7f74fc4 --- /dev/null +++ b/app/screens/channel/channel_post_list/channel_post_list.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useRef} from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; + +import {fetchPostsBefore} from '@actions/remote/post'; +import PostList from '@components/post_list'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {debounce} from '@helpers/api/general'; +import {sortPostsByNewest} from '@utils/post'; + +import Intro from './intro'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + channelId: string; + contentContainerStyle?: StyleProp; + currentTimezone: string | null; + currentUsername: string; + isTimezoneEnabled: boolean; + lastViewedAt: number; + posts: PostModel[]; + shouldShowJoinLeaveMessages: boolean; +} + +const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages}: Props) => { + const serverUrl = useServerUrl(); + const canLoadPosts = useRef(true); + const fetchingPosts = useRef(false); + + const onEndReached = useCallback(debounce(async () => { + if (!fetchingPosts.current && canLoadPosts.current && posts.length) { + fetchingPosts.current = true; + const lastPost = sortPostsByNewest(posts)[0]; + const result = await fetchPostsBefore(serverUrl, channelId, lastPost.id); + canLoadPosts.current = ((result as ProcessedPosts).posts?.length ?? 1) > 0; + fetchingPosts.current = false; + } + }, 500), [channelId, posts]); + + const intro = useMemo(() => ( + + ), [channelId]); + + return ( + + ); +}; + +export default ChannelPostList; + diff --git a/app/screens/channel/channel_post_list/index.ts b/app/screens/channel/channel_post_list/index.ts new file mode 100644 index 0000000000..33b0de79ef --- /dev/null +++ b/app/screens/channel/channel_post_list/index.ts @@ -0,0 +1,71 @@ +// 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 {AppStateStatus} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getPreferenceAsBool} from '@helpers/api/preference'; +import {getTimezone} from '@utils/user'; + +import ChannelPostList from './channel_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'; +import type PostsInChannelModel from '@typings/database/models/servers/posts_in_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: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES; + +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)), + ); + + return { + currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), + currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))), + isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), + ), + lastViewedAt: database.get(MY_CHANNEL).findAndObserve(channelId).pipe( + switchMap((myChannel) => of$(myChannel.viewedAt)), + ), + posts: database.get(POSTS_IN_CHANNEL).query( + Q.where('channel_id', channelId), + Q.sortBy('latest', Q.desc), + ).observeWithColumns(['earliest', 'latest']).pipe( + switchMap((postsInChannel) => { + if (!postsInChannel.length) { + return of$([]); + } + + const {earliest, latest} = postsInChannel[0]; + return database.get(POST).query( + Q.and( + Q.where('delete_at', 0), + Q.where('channel_id', channelId), + Q.where('create_at', Q.between(earliest, latest)), + ), + Q.sortBy('create_at', Q.desc), + ).observe(); + }), + ), + shouldShowJoinLeaveMessages: database.get(PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_ADVANCED_SETTINGS), + Q.where('name', Preferences.ADVANCED_FILTER_JOIN_LEAVE), + ).observe().pipe( + switchMap((preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))), + ), + }; +}); + +export default withDatabase(enhanced(ChannelPostList)); diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx new file mode 100644 index 0000000000..98c9140adb --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/direct_channel/group/group.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/group/group.tsx new file mode 100644 index 0000000000..cb37ac5638 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/direct_channel/group/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/group/index.ts new file mode 100644 index 0000000000..920750bfde --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/direct_channel/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/index.ts new file mode 100644 index 0000000000..f24a666608 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/direct_channel/member/index.ts b/app/screens/channel/channel_post_list/intro/direct_channel/member/index.ts new file mode 100644 index 0000000000..2e1577dcb8 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/direct_channel/member/member.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/member/member.tsx new file mode 100644 index 0000000000..24b1d5515a --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/illustration/private.tsx b/app/screens/channel/channel_post_list/intro/illustration/private.tsx new file mode 100644 index 0000000000..00b0e6401f --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/illustration/public.tsx b/app/screens/channel/channel_post_list/intro/illustration/public.tsx new file mode 100644 index 0000000000..7d9265e2aa --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/index.ts b/app/screens/channel/channel_post_list/intro/index.ts new file mode 100644 index 0000000000..02623184a7 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/intro.tsx b/app/screens/channel/channel_post_list/intro/intro.tsx new file mode 100644 index 0000000000..47ad1d9c94 --- /dev/null +++ b/app/screens/channel/channel_post_list/intro/intro.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo, useState} from 'react'; +import {ActivityIndicator, DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native'; + +import {Events, 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, + paddingTop: 50, + overflow: 'hidden', + ...Platform.select({ + android: { + scaleY: -1, + }, + }), + }, +}); + +const Intro = ({channel, loading = false, roles}: Props) => { + const [fetching, setFetching] = useState(false); + 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]); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.LOADING_CHANNEL_POSTS, (value: boolean) => { + setFetching(value); + }); + + return () => listener.remove(); + }, []); + + if (loading || fetching) { + return ( + + ); + } + + return ( + + {element} + + ); +}; + +export default Intro; diff --git a/app/screens/channel/channel_post_list/intro/options/favorite/favorite.tsx b/app/screens/channel/channel_post_list/intro/options/favorite/favorite.tsx new file mode 100644 index 0000000000..7c6bcdd6d1 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/options/favorite/index.ts b/app/screens/channel/channel_post_list/intro/options/favorite/index.ts new file mode 100644 index 0000000000..5f4a0eec3f --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/options/index.tsx b/app/screens/channel/channel_post_list/intro/options/index.tsx new file mode 100644 index 0000000000..774791f611 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/options/item.tsx b/app/screens/channel/channel_post_list/intro/options/item.tsx new file mode 100644 index 0000000000..e36ae444d4 --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/public_or_private_channel/index.ts b/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts new file mode 100644 index 0000000000..39c657301c --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx new file mode 100644 index 0000000000..a85f3da91c --- /dev/null +++ b/app/screens/channel/channel_post_list/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/channel_post_list/intro/townsquare/index.tsx b/app/screens/channel/channel_post_list/intro/townsquare/index.tsx new file mode 100644 index 0000000000..04fb5ab04a --- /dev/null +++ b/app/screens/channel/channel_post_list/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/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 5c69a333a4..e24890256d 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -1,106 +1,99 @@ // 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 React, {useEffect, useState} from 'react'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {map} from 'rxjs/operators'; +import {combineLatest, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; -import {fetchPostsForChannel} from '@actions/remote/post'; -import PostList from '@components/post_list'; -import {Database} from '@constants'; -import {useServerUrl} from '@context/server'; -import {useTheme} from '@context/theme'; -import {useAppState} from '@hooks/device'; -import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getUserIdFromChannelName} from '@app/utils/user'; +import {Database, General} from '@constants'; -import ChannelNavBar from './channel_nav_bar'; -import FailedChannels from './failed_channels'; -import FailedTeams from './failed_teams'; -import Intro from './intro'; +import Channel from './channel'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type SystemModel from '@typings/database/models/servers/system'; -import type {LaunchProps} from '@typings/launch'; - -type ChannelProps = LaunchProps & { - currentChannelId: string; - currentTeamId: string; -}; +import type UserModel from '@typings/database/models/servers/user'; const {MM_TABLES, SYSTEM_IDENTIFIERS} = Database; -const {SERVER: {SYSTEM}} = MM_TABLES; +const {SERVER: {CHANNEL, CHANNEL_INFO, SYSTEM, USER}} = MM_TABLES; -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - flex: { - flex: 1, - }, - sectionContainer: { - marginTop: 10, - paddingHorizontal: 24, - }, - sectionTitle: { - fontSize: 16, - fontFamily: 'OpenSans-Semibold', - color: theme.centerChannelColor, - }, -})); - -const Channel = ({currentChannelId, currentTeamId}: ChannelProps) => { - const appState = useAppState(); - const [loading, setLoading] = useState(false); - const serverUrl = useServerUrl(); - const theme = useTheme(); - const styles = getStyleSheet(theme); - - useEffect(() => { - setLoading(true); - fetchPostsForChannel(serverUrl, currentChannelId).then(() => { - setLoading(false); - }); - }, [currentChannelId]); - - if (!currentTeamId) { - return ; - } - - if (!currentChannelId) { - return ; - } - - return ( - - null} - /> - - )} - forceQueryAfterAppState={appState} - testID='channel.post_list' - /> - +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const currentUserId = database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}: {value: string}) => of$(value)), ); -}; -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - currentChannelId: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe( - map(({value}: {value: string}) => value), - ), - currentTeamId: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( - map(({value}: {value: string}) => value), - ), -})); + const channelId = database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe( + switchMap(({value}: {value: string}) => of$(value)), + ); + const teamId = database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap(({value}: {value: string}) => of$(value)), + ); + + const channel = channelId.pipe( + switchMap((id) => database.get(CHANNEL).query(Q.where('id', id)).observe().pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((channels) => { + if (channels.length) { + return channels[0].observe(); + } + + return of$(null); + }), + )), + ); + + const channelInfo = channelId.pipe( + switchMap((id) => database.get(CHANNEL_INFO).query(Q.where('id', id)).observe().pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((infos) => { + if (infos.length) { + return infos[0].observe(); + } + + return of$(null); + }), + )), + ); + + const isOwnDirectMessage = combineLatest([currentUserId, channel]).pipe( + switchMap(([userId, ch]) => { + if (ch?.type === General.DM_CHANNEL) { + const teammateId = getUserIdFromChannelName(userId, ch.name); + return of$(userId === teammateId); + } + + return of$(false); + }), + ); + + const displayName = channel.pipe(switchMap((c) => of$(c?.displayName))); + const name = combineLatest([currentUserId, channel]).pipe(switchMap(([userId, c]) => { + if (c?.type === General.DM_CHANNEL) { + const teammateId = getUserIdFromChannelName(userId, c.name); + return database.get(USER).findAndObserve(teammateId).pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((u) => of$(`@${u.username}`)), + ); + } else if (c?.type === General.GM_CHANNEL) { + return of$(`@${c.name}`); + } + + return of$(c?.name); + })); + const memberCount = channelInfo.pipe(switchMap((ci) => of$(ci?.memberCount || 0))); + + return { + channelId, + displayName, + isOwnDirectMessage, + memberCount, + name, + teamId, + }; +}); export default withDatabase(enhanced(Channel)); diff --git a/app/screens/channel/intro/options/item.tsx b/app/screens/channel/intro/options/item.tsx index e36ae444d4..041dc90bcc 100644 --- a/app/screens/channel/intro/options/item.tsx +++ b/app/screens/channel/intro/options/item.tsx @@ -55,23 +55,32 @@ const IntroItem = ({applyMargin, color, iconName, label, onPress, theme}: Props) return style; }, [applyMargin, theme]); + const renderPressableChildren = ({pressed}: PressableStateCallbackType) => { + let pressedColor = color || changeOpacity(theme.centerChannelColor, 0.56); + if (pressed) { + pressedColor = theme.linkColor; + } + + return ( + <> + + + {label} + + + ); + }; + return ( - {({pressed}) => ( - <> - - - {label} - - - )} + {renderPressableChildren} ); }; diff --git a/app/screens/channel/other_mentions_badge/index.tsx b/app/screens/channel/other_mentions_badge/index.tsx new file mode 100644 index 0000000000..3f950d4ba7 --- /dev/null +++ b/app/screens/channel/other_mentions_badge/index.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import React, {useEffect, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; + +import Badge from '@components/badge'; +import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +import type ServersModel from '@typings/database/models/app/servers'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type {Subscription} from 'rxjs'; + +type UnreadSubscription = { + mentions: number; + subscription?: Subscription; +} + +type Props = { + channelId: string; +} + +const styles = StyleSheet.create({ + badge: { + left: 2, + position: 'relative', + top: 0, + }, +}); + +const {SERVERS} = MM_TABLES.APP; +const {CHANNEL, MY_CHANNEL} = MM_TABLES.SERVER; +const subscriptions: Map = new Map(); + +const OtherMentionsBadge = ({channelId}: Props) => { + const db = DatabaseManager.appDatabase?.database; + const [count, setCount] = useState(0); + + const updateCount = () => { + let mentions = 0; + subscriptions.forEach((value) => { + mentions += value.mentions; + }); + setCount(mentions); + }; + + const unreadsSubscription = (serverUrl: string, myChannels: MyChannelModel[]) => { + const unreads = subscriptions.get(serverUrl); + if (unreads) { + let mentions = 0; + myChannels.forEach((myChannel) => { + if (channelId !== myChannel.id) { + mentions += myChannel.mentionsCount; + } + }); + + unreads.mentions = mentions; + subscriptions.set(serverUrl, unreads); + updateCount(); + } + }; + + const serversObserver = async (servers: ServersModel[]) => { + servers.forEach((server) => { + const serverUrl = server.url; + if (server.lastActiveAt) { + const sdb = DatabaseManager.serverDatabases[serverUrl]; + if (sdb?.database) { + if (!subscriptions.has(serverUrl)) { + const unreads: UnreadSubscription = { + mentions: 0, + }; + subscriptions.set(serverUrl, unreads); + unreads.subscription = sdb.database. + get(MY_CHANNEL). + query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). + observeWithColumns(['mentions_count']). + subscribe(unreadsSubscription.bind(undefined, serverUrl)); + } + } + + // subscribe and listen for mentions + } else if (subscriptions.has(serverUrl)) { + // logout from server, remove the subscription + subscriptions.delete(serverUrl); + } + }); + }; + + useEffect(() => { + const subscription = db?. + get(SERVERS). + query(). + observeWithColumns(['last_active_at']). + subscribe(serversObserver); + + return () => { + subscription?.unsubscribe(); + subscriptions.forEach((unreads) => { + unreads.subscription?.unsubscribe(); + }); + }; + }, []); + + return ( + + 0} + value={count} + style={styles.badge} + borderColor='transparent' + /> + + ); +}; + +export default OtherMentionsBadge; diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 545851c29f..97c7170d49 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -4,9 +4,8 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import {useIsFocused, useRoute} from '@react-navigation/native'; import React from 'react'; -import {View} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; -import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; +import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import ChannelList from '@components/channel_list'; import TeamSidebar from '@components/team_sidebar'; @@ -16,13 +15,12 @@ import Channel from '@screens/channel'; import ServerIcon from '@screens/home/channel_list/server_icon/server_icon'; import {makeStyleSheetFromTheme} from '@utils/theme'; -import type {LaunchProps} from '@typings/launch'; - -type ChannelProps = LaunchProps & { +type ChannelProps = { teamsCount: number; time?: number; }; +const edges: Edge[] = ['bottom', 'left', 'right']; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ flex: { flex: 1, @@ -71,12 +69,16 @@ const ChannelListScreen = (props: ChannelProps) => { }; }, [isFocused, params]); + const top = useAnimatedStyle(() => { + return {height: insets.top, backgroundColor: theme.sidebarBg}; + }, [theme]); + return ( <> - {Boolean(insets.top) && } + {} {canAddOtherServers && } { teamsCount={props.teamsCount} /> {isTablet && - + } diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 8d72eae68c..193f5d9a48 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -8,7 +8,7 @@ import {useIntl} from 'react-intl'; import {DeviceEventEmitter, Platform} from 'react-native'; import {enableScreens} from 'react-native-screens'; -import {Events} from '@constants'; +import {Events, Screens} from '@constants'; import {useTheme} from '@context/theme'; import {notificationError} from '@utils/notification'; @@ -66,22 +66,22 @@ export default function HomeScreen(props: HomeProps) { />)} > {() => } diff --git a/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx b/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx index 4e4aa0bd95..684d846717 100644 --- a/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx +++ b/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx @@ -16,7 +16,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { flex: 1, flexDirection: 'row', - marginTop: 8, + marginVertical: 8, }, channel: { ...typography('Body', 75, 'SemiBold'), diff --git a/app/screens/home/recent_mentions/components/mention/mention.tsx b/app/screens/home/recent_mentions/components/mention/mention.tsx index 5f98e06f47..000472f7d5 100644 --- a/app/screens/home/recent_mentions/components/mention/mention.tsx +++ b/app/screens/home/recent_mentions/components/mention/mention.tsx @@ -32,14 +32,14 @@ const styles = StyleSheet.create({ flexDirection: 'row', paddingBottom: 8, }, - rightColumn: { - flex: 1, - flexDirection: 'column', - marginRight: 12, - }, message: { flex: 1, }, + profilePictureContainer: { + marginBottom: 5, + marginRight: 10, + marginTop: 10, + }, }); function Mention({post, currentUser}: Props) { @@ -50,14 +50,18 @@ function Mention({post, currentUser}: Props) { const isWebHook = isFromWebhook(post); const isEdited = postEdited(post); - const postAvatar = isAutoResponder ? ( - - ) : ( - + const postAvatar = ( + + {isAutoResponder ? ( + + ) : ( + + )} + ); const header = isSystemPost && !isAutoResponder ? ( @@ -85,7 +89,7 @@ function Mention({post, currentUser}: Props) { {postAvatar} - + {header} { const intl = useIntl(); const searchScreenIndex = 1; const stateIndex = nav.getState().index; + const {searchTerm} = nav.getState().routes[stateIndex].params; const animated = useAnimatedStyle(() => { if (isFocused) { @@ -113,6 +114,7 @@ const SearchScreen = () => { }} blurOnSubmit={true} placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})} + defaultValue={searchTerm} /> listner.remove(); }); + useEffect(() => { + const listner = DeviceEventEmitter.addListener(NavigationConstants.NAVIGATE_TO_TAB, ({screen, params = {}}: {screen: string; params: any}) => { + const lastTab = state.history[state.history.length - 1]; + // eslint-disable-next-line max-nested-callbacks + const routeIndex = state.routes.findIndex((r) => r.name === screen); + const route = state.routes[routeIndex]; + // eslint-disable-next-line max-nested-callbacks + const lastIndex = state.routes.findIndex((r) => r.key === lastTab.key); + const direction = lastIndex < routeIndex ? 'right' : 'left'; + const event = navigation.emit({ + type: 'tabPress', + target: screen, + canPreventDefault: true, + }); + + if (!event.defaultPrevented) { + // The `merge: true` option makes sure that the params inside the tab screen are preserved + navigation.navigate({params: {direction, ...params}, name: route.name, merge: false}); + EphemeralStore.setVisibleTap(route.name); + } + }); + + return () => listner.remove(); + }, [state]); + const transform = useAnimatedStyle(() => { const translateX = withTiming(state.index * tabWidth, {duration: 150}); return { @@ -140,7 +165,7 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th if (!isFocused && !event.defaultPrevented) { // The `merge: true` option makes sure that the params inside the tab screen are preserved - navigation.navigate({params: {...route.params, direction}, name: route.name, merge: true}); + navigation.navigate({params: {direction}, name: route.name, merge: false}); EphemeralStore.setVisibleTap(route.name); } }; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 54d2fc6c01..71d1005872 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -92,7 +92,7 @@ Appearance.addChangeListener(() => { } }); -function getThemeFromState() { +function getThemeFromState(): Theme { if (EphemeralStore.theme) { return EphemeralStore.theme; } @@ -241,9 +241,10 @@ export function resetToTeams(name: string, title: string, passProps = {}, option export function goToScreen(name: string, title: string, passProps = {}, options = {}) { const theme = getThemeFromState(); + const isDark = tinyColor(theme.sidebarBg).isDark(); const componentId = EphemeralStore.getNavigationTopComponentId(); DeviceEventEmitter.emit('tabBarVisible', false); - const defaultOptions = { + const defaultOptions: Options = { layout: { componentBackgroundColor: theme.centerChannelBg, }, @@ -252,6 +253,10 @@ export function goToScreen(name: string, title: string, passProps = {}, options left: {enabled: false}, right: {enabled: false}, }, + statusBar: { + backgroundColor: null, + style: isDark ? 'light' : 'dark', + }, topBar: { animate: true, visible: true, diff --git a/app/screens/server/form.tsx b/app/screens/server/form.tsx index b6369d134c..879e2bccd1 100644 --- a/app/screens/server/form.tsx +++ b/app/screens/server/form.tsx @@ -192,7 +192,6 @@ const ServerForm = ({ autoCapitalize={'none'} enablesReturnKeyAutomatically={true} error={displayNameError} - keyboardType='url' label={formatMessage({ id: 'mobile.components.select_server_view.displayName', defaultMessage: 'Display Name', diff --git a/app/utils/emoji/helpers.ts b/app/utils/emoji/helpers.ts index f0a657cde0..fb2fb82b46 100644 --- a/app/utils/emoji/helpers.ts +++ b/app/utils/emoji/helpers.ts @@ -32,7 +32,7 @@ const RE_EMOTICON: Record = { broken_heart: /(^|\s)(<\/3|</3)(?=$|\s)/g, // { + return posts.sort((a, b) => { + if (a.createAt > b.createAt) { + return 1; + } + + return -1; + }); +}; diff --git a/app/utils/theme/index.ts b/app/utils/theme/index.ts index 4b34e2edd9..b341cb870a 100644 --- a/app/utils/theme/index.ts +++ b/app/utils/theme/index.ts @@ -93,6 +93,7 @@ export function concatStyles(...styles: T[]) { } export function setNavigatorStyles(componentId: string, theme: Theme, additionalOptions: Options = {}, statusBarColor?: string) { + const isDark = tinyColor(statusBarColor || theme.sidebarBg).isDark(); const options: Options = { topBar: { title: { @@ -106,6 +107,7 @@ export function setNavigatorStyles(componentId: string, theme: Theme, additional }, statusBar: { backgroundColor: theme.sidebarBg, + style: isDark ? 'light' : 'dark', }, layout: { componentBackgroundColor: theme.centerChannelBg, @@ -117,7 +119,6 @@ export function setNavigatorStyles(componentId: string, theme: Theme, additional color: theme.sidebarHeaderTextColor, }; } - const isDark = tinyColor(statusBarColor || theme.sidebarBg).isDark(); StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content'); const mergeOptions = { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 22f85c2ae3..55bbd93786 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -24,12 +24,10 @@ "apps.error.responses.unexpected_error": "Received an unexpected error.", "apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.", "apps.error.unknown": "Unknown error occurred.", + "channel": "{count, plural, one {# member} other {# members}}", "channel_header.directchannel.you": "{displayname} (you)", "channel_loader.someone": "Someone", "channel_modal.optional": "(optional)", - "channel.channelHasGuests": "This channel has guests", - "channel.hasGuests": "This group message has guests", - "channel.isGuest": "This person is a guest", "combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.", "combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.", "combined_system_message.added_to_channel.one_you": "You were **added to the channel** by {actor}.", @@ -283,6 +281,7 @@ "modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?", + "more_messages.text": "{count} new {count, plural, one {message} other {messages}}", "notification.message_not_found": "Message not found", "notification.not_channel_member": "This message belongs to a channel where you are not a member.", "notification.not_team_member": "This message belongs to a team where you are not a member.", diff --git a/package-lock.json b/package-lock.json index f352d01e13..fe7c348a5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@react-navigation/native": "6.0.6", "@rudderstack/rudder-sdk-react-native": "1.1.1", "@sentry/react-native": "3.2.10", + "@stream-io/flat-list-mvcp": "0.10.1", "@types/mime-db": "1.43.1", "commonmark": "0.30.0", "commonmark-react-renderer": "4.3.5", @@ -5272,6 +5273,18 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@stream-io/flat-list-mvcp": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@stream-io/flat-list-mvcp/-/flat-list-mvcp-0.10.1.tgz", + "integrity": "sha512-/snvyGqEO/7WKrcFOUxh1s1GPfYaUOwr7wyWgZogOUrGXE75zzEvOe39mooMoCJ8G92govZoAO5LCkftXQUoAQ==", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", @@ -27840,6 +27853,14 @@ "@sinonjs/commons": "^1.7.0" } }, + "@stream-io/flat-list-mvcp": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@stream-io/flat-list-mvcp/-/flat-list-mvcp-0.10.1.tgz", + "integrity": "sha512-/snvyGqEO/7WKrcFOUxh1s1GPfYaUOwr7wyWgZogOUrGXE75zzEvOe39mooMoCJ8G92govZoAO5LCkftXQUoAQ==", + "requires": { + "lodash.debounce": "^4.0.8" + } + }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", diff --git a/package.json b/package.json index 1956285cc1..c3aae0270c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@react-navigation/native": "6.0.6", "@rudderstack/rudder-sdk-react-native": "1.1.1", "@sentry/react-native": "3.2.10", + "@stream-io/flat-list-mvcp": "0.10.1", "@types/mime-db": "1.43.1", "commonmark": "0.30.0", "commonmark-react-renderer": "4.3.5", diff --git a/patches/react-native+0.66.4.patch b/patches/react-native+0.66.4.patch index 1352d0622a..0b2b5098e3 100644 --- a/patches/react-native+0.66.4.patch +++ b/patches/react-native+0.66.4.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js b/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js -index 8eca425..f0df04f 100644 +index e497288..5465e97 100644 --- a/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js -@@ -1768,9 +1768,15 @@ class ScrollView extends React.Component { +@@ -1776,9 +1776,15 @@ class ScrollView extends React.Component { // Note: we should split props.style on the inner and outer props // however, the ScrollView still needs the baseStyle to be scrollable const {outer, inner} = splitLayoutProps(flattenStyle(props.style)); @@ -20,7 +20,7 @@ index 8eca425..f0df04f 100644 {...props} style={StyleSheet.compose(baseStyle, inner)} diff --git a/node_modules/react-native/Libraries/Lists/VirtualizedList.js b/node_modules/react-native/Libraries/Lists/VirtualizedList.js -index a7c1567..1531a45 100644 +index 2648cc3..eee7c9a 100644 --- a/node_modules/react-native/Libraries/Lists/VirtualizedList.js +++ b/node_modules/react-native/Libraries/Lists/VirtualizedList.js @@ -16,6 +16,7 @@ const ScrollView = require('../Components/ScrollView/ScrollView'); @@ -31,7 +31,158 @@ index a7c1567..1531a45 100644 const flattenStyle = require('../StyleSheet/flattenStyle'); const infoLog = require('../Utilities/infoLog'); -@@ -2122,7 +2123,14 @@ function describeNestedLists(childList: { +@@ -514,29 +515,29 @@ class VirtualizedList extends React.PureComponent { + * Param `animated` (`true` by default) defines whether the list + * should do an animation while scrolling. + */ +- scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) { +- const {animated, offset} = params; ++scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) { ++ const {animated, offset} = params; + +- if (this._scrollRef == null) { +- return; +- } +- +- if (this._scrollRef.scrollTo == null) { +- console.warn( +- 'No scrollTo method provided. This may be because you have two nested ' + +- 'VirtualizedLists with the same orientation, or because you are ' + +- 'using a custom component that does not implement scrollTo.', +- ); +- return; +- } ++ if (this._scrollRef == null) { ++ return; ++ } + +- this._scrollRef.scrollTo( +- horizontalOrDefault(this.props.horizontal) +- ? {x: offset, animated} +- : {y: offset, animated}, ++ if (this._scrollRef.scrollTo == null) { ++ console.warn( ++ 'No scrollTo method provided. This may be because you have two nested ' + ++ 'VirtualizedLists with the same orientation, or because you are ' + ++ 'using a custom component that does not implement scrollTo.', + ); ++ return; + } + ++ this._scrollRef.scrollTo( ++ horizontalOrDefault(this.props.horizontal) ++ ? {x: offset, animated} ++ : {y: offset, animated}, ++ ); ++} ++ + recordInteraction() { + this._nestedChildLists.forEach(childList => { + childList.ref && childList.ref.recordInteraction(); +@@ -1221,6 +1222,7 @@ class VirtualizedList extends React.PureComponent { + _totalCellsMeasured = 0; + _updateCellsToRenderBatcher: Batchinator; + _viewabilityTuples: Array = []; ++ _hasDoneFirstScroll = false; + + _captureScrollRef = ref => { + this._scrollRef = ref; +@@ -1495,31 +1497,40 @@ class VirtualizedList extends React.PureComponent { + return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x; + } + +- _maybeCallOnEndReached() { +- const { +- data, +- getItemCount, +- onEndReached, +- onEndReachedThreshold, +- } = this.props; +- const {contentLength, visibleLength, offset} = this._scrollMetrics; +- const distanceFromEnd = contentLength - visibleLength - offset; +- const threshold = +- onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; ++ _maybeCallOnEndReached(hasShrunkContentLength: boolean = false) { ++ const {onEndReached, onEndReachedThreshold} = this.props; ++ if (!onEndReached) { ++ return; ++ } ++ const {contentLength, visibleLength, offset, dOffset} = this._scrollMetrics; ++ // If this is just after the initial rendering + if ( +- onEndReached && +- this.state.last === getItemCount(data) - 1 && +- distanceFromEnd < threshold && +- this._scrollMetrics.contentLength !== this._sentEndForContentLength ++ !hasShrunkContentLength && ++ !this._hasDoneFirstScroll && ++ offset === 0 + ) { +- // Only call onEndReached once for a given content length +- this._sentEndForContentLength = this._scrollMetrics.contentLength; +- onEndReached({distanceFromEnd}); +- } else if (distanceFromEnd > threshold) { +- // If the user scrolls away from the end and back again cause +- // an onEndReached to be triggered again +- this._sentEndForContentLength = 0; ++ return; ++ } ++ // If scrolled up in the vertical list ++ if (dOffset < 0) { ++ return; ++ } ++ // If contentLength has not changed ++ if (contentLength === this._sentEndForContentLength) { ++ return; ++ } ++ const distanceFromEnd = contentLength - visibleLength - offset; ++ // If the distance is so farther than the area shown on the screen ++ if (distanceFromEnd >= visibleLength * 1.5) { ++ return; ++ } ++ // $FlowFixMe ++ const minimumDistanceFromEnd = onEndReachedThreshold !== null ? onEndReachedThreshold * visibleLength : 2; ++ if (distanceFromEnd >= minimumDistanceFromEnd) { ++ return; + } ++ this._sentEndForContentLength = contentLength; ++ onEndReached({distanceFromEnd}); + } + + _onContentSizeChange = (width: number, height: number) => { +@@ -1541,9 +1552,21 @@ class VirtualizedList extends React.PureComponent { + if (this.props.onContentSizeChange) { + this.props.onContentSizeChange(width, height); + } +- this._scrollMetrics.contentLength = this._selectLength({height, width}); ++ const {contentLength: currentContentLength} = this._scrollMetrics; ++ const contentLength = this._selectLength({height, width}); ++ this._scrollMetrics.contentLength = contentLength; + this._scheduleCellsToRenderUpdate(); +- this._maybeCallOnEndReached(); ++ const hasShrunkContentLength = ++ currentContentLength > 0 && ++ contentLength > 0 && ++ contentLength < currentContentLength; ++ if ( ++ hasShrunkContentLength && ++ this._sentEndForContentLength >= contentLength ++ ) { ++ this._sentEndForContentLength = 0; ++ } ++ this._maybeCallOnEndReached(hasShrunkContentLength); + }; + + /* Translates metrics from a scroll event in a parent VirtualizedList into +@@ -1631,6 +1654,7 @@ class VirtualizedList extends React.PureComponent { + if (!this.props) { + return; + } ++ this._hasDoneFirstScroll = true; + this._maybeCallOnEndReached(); + if (velocity !== 0) { + this._fillRateHelper.activate(); +@@ -2119,7 +2143,14 @@ function describeNestedLists(childList: { const styles = StyleSheet.create({ verticallyInverted: { @@ -48,7 +199,7 @@ index a7c1567..1531a45 100644 horizontallyInverted: { transform: [{scaleX: -1}], diff --git a/node_modules/react-native/react.gradle b/node_modules/react-native/react.gradle -index 84b1f60..0ffc592 100644 +index ff46476..90e66db 100644 --- a/node_modules/react-native/react.gradle +++ b/node_modules/react-native/react.gradle @@ -151,7 +151,7 @@ afterEvaluate { @@ -78,7 +229,7 @@ index 84b1f60..0ffc592 100644 } // Expose a minimal interface on the application variant and the task itself: -@@ -321,7 +321,7 @@ afterEvaluate { +@@ -328,7 +328,7 @@ afterEvaluate { // This should really be done by packaging all Hermes related libs into // two separate HermesDebug and HermesRelease AARs, but until then we'll // kludge it by deleting the .so files out of the /transforms/ directory. diff --git a/types/api/channels.d.ts b/types/api/channels.d.ts index 417bc40e6f..bf7043dca1 100644 --- a/types/api/channels.d.ts +++ b/types/api/channels.d.ts @@ -3,6 +3,7 @@ type ChannelType = 'O' | 'P' | 'D' | 'G'; type ChannelStats = { channel_id: string; + guest_count: number; member_count: number; pinnedpost_count: number; }; diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index eecc676f2d..d180c88fd1 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -69,53 +69,23 @@ type Post = { participants: null|string[]; }; -type PostWithFormatData = Post & { - isFirstReply: boolean; - isLastReply: boolean; - previousPostIsComment: boolean; - commentedOnPost?: Post; - consecutivePostByUser: boolean; - replyCount: number; - isCommentMention: boolean; - highlight: boolean; -}; - -type PostOrderBlock = { - order: string[]; - recent?: boolean; - oldest?: boolean; -}; - -type MessageHistory = { - messages: string[]; - index: { - post: number; - comment: number; - }; -}; - -type PostsState = { - posts: IDMappedObjects; - postsInChannel: Dictionary; - postsInThread: RelationOneToMany; - reactions: RelationOneToOne>; - openGraph: RelationOneToOne; - pendingPostIds: string[]; - selectedPostId: string; - currentFocusedPostId: string; - messagesHistory: MessageHistory; - expandedURLs: Dictionary; -}; - type PostProps = { disable_group_highlight?: boolean; mentionHighlightDisabled: boolean; }; -type PostResponse = PostOrderBlock & { +type PostResponse = { + order: string[]; posts: IDMappedObjects; + prev_post_id?: string; }; +type ProcessedPosts = { + order: string[]; + posts: Post[]; + previousPostId?: string; +} + type MessageAttachment = { id: number; fallback: string; diff --git a/types/database/models/servers/post.d.ts b/types/database/models/servers/post.d.ts index 24899e73d3..944bfbcff1 100644 --- a/types/database/models/servers/post.d.ts +++ b/types/database/models/servers/post.d.ts @@ -78,4 +78,7 @@ export default class PostModel extends Model { /** channel: The channel which is presenting this Post */ channel: Relation; + + /** hasReplies: Async function to determine if the post is part of a thread */ + hasReplies: () => Promise; }