diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 89e3c72c6d..f208679686 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -103,7 +103,7 @@ export async function switchToChannel(serverUrl: string, channelId: string, team if (isTabletDevice) { dismissAllModalsAndPopToRoot(); - DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME); + DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME, Screens.CHANNEL); } else { dismissAllModalsAndPopToScreen(Screens.CHANNEL, '', undefined, {topBar: {visible: false}}); } diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 644c922566..b503cdcdfc 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -151,7 +151,7 @@ export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRe }); if (!prepareRecordsOnly) { - operator.batchRecords([dbPost]); + await operator.batchRecords([dbPost]); } return {model}; } diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index 525703da22..7e6caf225b 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -1,19 +1,53 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ActionType, General, Screens} from '@constants'; +import {DeviceEventEmitter} from 'react-native'; + +import {ActionType, General, Navigation, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getTranslations, t} from '@i18n'; import {getChannelById} from '@queries/servers/channel'; import {getPostById} from '@queries/servers/post'; +import {getCurrentTeamId, setCurrentChannelId} from '@queries/servers/system'; +import {addChannelToTeamHistory} from '@queries/servers/team'; import {getIsCRTEnabled, getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import {goToScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; +import {isTablet} from '@utils/helpers'; import {changeOpacity} from '@utils/theme'; import type Model from '@nozbe/watermelondb/Model'; +export const switchToGlobalThreads = async (serverUrl: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const {database} = operator; + const models: Model[] = []; + try { + await setCurrentChannelId(operator, ''); + const currentTeamId = await getCurrentTeamId(database); + const history = await addChannelToTeamHistory(operator, currentTeamId, Screens.GLOBAL_THREADS, true); + models.push(...history); + const isTabletDevice = await isTablet(); + if (isTabletDevice) { + DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS); + } else { + goToScreen(Screens.GLOBAL_THREADS, '', {}, {topBar: {visible: false}}); + } + if (!prepareRecordsOnly) { + await operator.batchRecords(models); + } + } catch (error) { + return {error}; + } + + return {models}; +}; + export const switchToThread = async (serverUrl: string, rootId: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -136,7 +170,7 @@ export async function createThreadFromNewPost(serverUrl: string, post: Post, pre } // On receiving threads, Along with the "threads" & "thread participants", extract and save "posts" & "users" -export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, prepareRecordsOnly = false, loadedInGlobalThreads = false) { +export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, loadedInGlobalThreads = false, prepareRecordsOnly = false) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 467d76843a..7d636cacae 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -7,7 +7,8 @@ import {IntlShape} from 'react-intl'; import {storeCategories} from '@actions/local/category'; import {addChannelToDefaultCategory, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; -import {General, Preferences} from '@constants'; +import {switchToGlobalThreads} from '@actions/local/thread'; +import {General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; @@ -955,6 +956,9 @@ export async function switchToPenultimateChannel(serverUrl: string) { try { const currentTeam = await getCurrentTeamId(database); const channelId = await getNthLastChannelFromTeam(database, currentTeam, 1); + if (channelId === Screens.GLOBAL_THREADS) { + return switchToGlobalThreads(serverUrl); + } return switchToChannelById(serverUrl, channelId); } catch (error) { return {error}; diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index a430d2455a..5804a72dbd 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -1,13 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {switchToGlobalThreads} from '@actions/local/thread'; import {switchToChannelById} from '@actions/remote/channel'; import {fetchRoles} from '@actions/remote/role'; import {fetchConfigAndLicense} from '@actions/remote/systems'; +import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {queryChannelsById, getDefaultChannelForTeam} from '@queries/servers/channel'; import {prepareModels} from '@queries/servers/entry'; -import {prepareCommonSystemValues, getCommonSystemValues, getCurrentChannelId, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {prepareCommonSystemValues, getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {getNthLastChannelFromTeam} from '@queries/servers/team'; import {getCurrentUser} from '@queries/servers/user'; import {deleteV1Data} from '@utils/file'; import {isTablet} from '@utils/helpers'; @@ -46,16 +49,13 @@ export async function appEntry(serverUrl: string, since = 0) { const rolesData = await fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user, true); if (initialTeamId === currentTeamId) { - let cId = await getCurrentChannelId(database); if (tabletDevice) { - if (!cId) { - const channel = await getDefaultChannelForTeam(database, initialTeamId); - if (channel) { - cId = channel.id; - } + const cId = await getNthLastChannelFromTeam(database, currentTeamId); + if (cId === Screens.GLOBAL_THREADS) { + switchToGlobalThreads(serverUrl); + } else { + switchToChannelById(serverUrl, cId, initialTeamId); } - - switchToChannelById(serverUrl, cId, initialTeamId); } } else { // Immediately set the new team as the current team in the database so that the UI diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 94dc000740..93bdde7cd0 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -5,7 +5,8 @@ import {Model} from '@nozbe/watermelondb'; import {DeviceEventEmitter} from 'react-native'; import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team'; -import {Events} from '@constants'; +import {switchToGlobalThreads} from '@actions/local/thread'; +import {Events, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categories'; @@ -268,7 +269,12 @@ export async function handleTeamChange(serverUrl: string, teamId: string) { if (await isTablet()) { channelId = await getNthLastChannelFromTeam(database, teamId); if (channelId) { - await switchToChannelById(serverUrl, channelId, teamId); + if (channelId === Screens.GLOBAL_THREADS) { + await switchToGlobalThreads(serverUrl); + } else { + await switchToChannelById(serverUrl, channelId, teamId); + } + completeTeamChange(serverUrl, teamId, channelId); return; } } @@ -287,6 +293,15 @@ export async function handleTeamChange(serverUrl: string, teamId: string) { await operator.batchRecords(models); } + completeTeamChange(serverUrl, teamId, channelId); +} + +const completeTeamChange = async (serverUrl: string, teamId: string, channelId: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + // If WebSocket is not disconnected we fetch everything since this moment const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || Date.now(); const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId, true, lastDisconnectedAt, false, true); @@ -298,4 +313,4 @@ export async function handleTeamChange(serverUrl: string, teamId: string) { if (channels?.length && memberships?.length) { fetchPostsForUnreadChannels(serverUrl, channels, memberships, channelId); } -} +}; diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index 9025c1cf25..c1a82fcfbe 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -105,7 +105,7 @@ export const fetchThreads = async ( thread.is_following = true; }); - await processReceivedThreads(serverUrl, threads, teamId, false, !unread); + await processReceivedThreads(serverUrl, threads, teamId, !unread, false); } return {data}; @@ -344,7 +344,7 @@ export async function fetchNewThreads( return {error: false, models: []}; } - const {error, models} = await processReceivedThreads(serverUrl, data, teamId, true, loadedInGlobalThreads); + const {error, models} = await processReceivedThreads(serverUrl, data, teamId, loadedInGlobalThreads, true); if (!error && !prepareRecordsOnly && models?.length) { try { diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index a141c3b591..9dabeaca48 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -13,11 +13,12 @@ import { switchToChannel, updateChannelInfoFromChannel, updateMyChannelFromWebsocket} from '@actions/local/channel'; +import {switchToGlobalThreads} from '@actions/local/thread'; import {fetchMissingSidebarInfo, fetchMyChannel, fetchChannelStats, fetchChannelById} from '@actions/remote/channel'; import {fetchPostsForChannel} from '@actions/remote/post'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user'; -import Events from '@constants/events'; +import {Events, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {queryActiveServer} from '@queries/app/servers'; import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel'; @@ -348,9 +349,16 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: if (await isTablet()) { const channelToJumpTo = await getNthLastChannelFromTeam(database, channel?.teamId); if (channelToJumpTo) { - const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', true); - if (switchChannelModels) { - models.push(...switchChannelModels); + if (channelToJumpTo === Screens.GLOBAL_THREADS) { + const {models: switchToGlobalThreadsModels} = await switchToGlobalThreads(serverUrl, true); + if (switchToGlobalThreadsModels) { + models.push(...switchToGlobalThreadsModels); + } + } else { + const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', true); + if (switchChannelModels) { + models.push(...switchChannelModels); + } } } // TODO else jump to "join a channel" screen https://mattermost.atlassian.net/browse/MM-41051 } else { @@ -410,6 +418,10 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke if (await isTablet()) { const channelToJumpTo = await getNthLastChannelFromTeam(database, currentChannel?.teamId); if (channelToJumpTo) { + if (channelToJumpTo === Screens.GLOBAL_THREADS) { + switchToGlobalThreads(serverUrl); + return; + } switchToChannel(serverUrl, channelToJumpTo); } // TODO else jump to "join a channel" screen } else { diff --git a/app/actions/websocket/threads.ts b/app/actions/websocket/threads.ts index 1d5bd605c5..d56c798546 100644 --- a/app/actions/websocket/threads.ts +++ b/app/actions/websocket/threads.ts @@ -11,7 +11,7 @@ export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocket // Mark it as following thread.is_following = true; - processReceivedThreads(serverUrl, [thread], teamId); + processReceivedThreads(serverUrl, [thread], teamId, true); } catch (error) { // Do nothing } diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx index 73d50454cc..6f981dd41c 100644 --- a/app/components/channel_item/channel_item.tsx +++ b/app/components/channel_item/channel_item.tsx @@ -35,7 +35,7 @@ type Props = { testID?: string; } -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ +export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { flexDirection: 'row', paddingHorizontal: 20, @@ -108,7 +108,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const textStyle = StyleSheet.create({ +export const textStyle = StyleSheet.create({ bright: typography('Body', 200, 'SemiBold'), regular: typography('Body', 200, 'Regular'), }); diff --git a/app/components/friendly_date/friendly_date.test.tsx b/app/components/friendly_date/friendly_date.test.tsx new file mode 100644 index 0000000000..73125f2580 --- /dev/null +++ b/app/components/friendly_date/friendly_date.test.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithIntl} from '@test/intl-test-helper'; + +import FriendlyDate from './index'; + +describe('Friendly Date', () => { + it('should render correctly', () => { + const justNow = new Date(); + justNow.setSeconds(justNow.getSeconds() - 10); + const justNowText = renderWithIntl( + , + ); + expect(justNowText.getByText('Now')).toBeTruthy(); + + const minutesAgo = new Date(); + minutesAgo.setMinutes(minutesAgo.getMinutes() - 1); + const minutesAgoText = renderWithIntl( + , + ); + expect(minutesAgoText.getByText('1 min ago')).toBeTruthy(); + + const hoursAgo = new Date(); + hoursAgo.setHours(hoursAgo.getHours() - 4); + const hoursAgoText = renderWithIntl( + , + ); + expect(hoursAgoText.getByText('4 hours ago')).toBeTruthy(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayText = renderWithIntl( + , + ); + expect(yesterdayText.getByText('Yesterday')).toBeTruthy(); + + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - 10); + const daysAgoText = renderWithIntl( + , + ); + expect(daysAgoText.getByText('10 days ago')).toBeTruthy(); + + // Difference is less than 30 days + const daysEdgeCase = new Date(2020, 3, 28); + const daysEdgeCaseTodayDate = new Date(2020, 4, 28); + const daysEdgeCaseText = renderWithIntl( + , + ); + expect(daysEdgeCaseText.getByText('1 month ago')).toBeTruthy(); + + const daysAgoMax = new Date(2020, 4, 6); + const daysAgoMaxTodayDate = new Date(2020, 5, 5); + const daysAgoMaxText = renderWithIntl( + , + ); + expect(daysAgoMaxText.getByText('30 days ago')).toBeTruthy(); + + const monthsAgo = new Date(); + monthsAgo.setMonth(monthsAgo.getMonth() - 2); + const monthsAgoText = renderWithIntl( + , + ); + expect(monthsAgoText.getByText('2 months ago')).toBeTruthy(); + + const yearsAgo = new Date(); + yearsAgo.setFullYear(yearsAgo.getFullYear() - 2); + const yearsAgoText = renderWithIntl( + , + ); + expect(yearsAgoText.getByText('2 years ago')).toBeTruthy(); + }); +}); diff --git a/app/components/friendly_date/index.tsx b/app/components/friendly_date/index.tsx new file mode 100644 index 0000000000..3dc0ac6aae --- /dev/null +++ b/app/components/friendly_date/index.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {IntlShape, useIntl} from 'react-intl'; +import {StyleProp, Text, ViewStyle} from 'react-native'; + +import {DateTime} from '@constants'; +import {isYesterday} from '@utils/datetime'; + +const {SECONDS} = DateTime; + +type Props = { + style?: StyleProp; + sourceDate?: number | Date; + value: number | Date; +}; + +function FriendlyDate({style, sourceDate, value}: Props) { + const intl = useIntl(); + const formattedTime = getFriendlyDate(intl, value, sourceDate); + return ( + {formattedTime} + ); +} + +export function getFriendlyDate(intl: IntlShape, inputDate: number | Date, sourceDate?: number | Date): string { + const today = sourceDate ? new Date(sourceDate) : new Date(); + const date = new Date(inputDate); + const difference = (today.getTime() - date.getTime()) / 1000; + + // Message: Now + if (difference < SECONDS.MINUTE) { + return intl.formatMessage({ + id: 'friendly_date.now', + defaultMessage: 'Now', + }); + } + + // Message: Minutes Ago + if (difference < SECONDS.HOUR) { + const minutes = Math.floor(Math.round((10 * difference) / SECONDS.MINUTE) / 10); + return intl.formatMessage({ + id: 'friendly_date.minsAgo', + defaultMessage: '{count} {count, plural, one {min} other {mins}} ago', + }, { + count: minutes, + }); + } + + // Message: Hours Ago + if (difference < SECONDS.DAY) { + const hours = Math.floor(Math.round((10 * difference) / SECONDS.HOUR) / 10); + return intl.formatMessage({ + id: 'friendly_date.hoursAgo', + defaultMessage: '{count} {count, plural, one {hour} other {hours}} ago', + }, { + count: hours, + }); + } + + // Message: Days Ago + if (difference < SECONDS.DAYS_31) { + if (isYesterday(date)) { + return intl.formatMessage({ + id: 'friendly_date.yesterday', + defaultMessage: 'Yesterday', + }); + } + const completedAMonth = today.getMonth() !== date.getMonth() && today.getDate() >= date.getDate(); + if (!completedAMonth) { + const days = Math.floor(Math.round((10 * difference) / SECONDS.DAY) / 10) || 1; + return intl.formatMessage({ + id: 'friendly_date.daysAgo', + defaultMessage: '{count} {count, plural, one {day} other {days}} ago', + }, { + count: days, + }); + } + } + + // Message: Months Ago + if (difference < SECONDS.DAYS_366) { + const completedAnYear = today.getFullYear() !== date.getFullYear() && + today.getMonth() >= date.getMonth() && + today.getDate() >= date.getDate(); + if (!completedAnYear) { + const months = Math.floor(Math.round((10 * difference) / SECONDS.DAYS_30) / 10) || 1; + return intl.formatMessage({ + id: 'friendly_date.monthsAgo', + defaultMessage: '{count} {count, plural, one {month} other {months}} ago', + }, { + count: months, + }); + } + } + + // Message: Years Ago + const years = Math.floor(Math.round((10 * difference) / SECONDS.DAYS_365) / 10) || 1; + return intl.formatMessage({ + id: 'friendly_date.yearsAgo', + defaultMessage: '{count} {count, plural, one {year} other {years}} ago', + }, { + count: years, + }); +} + +export default FriendlyDate; diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx index de9e5a8ff3..c87afbd8aa 100644 --- a/app/components/navigation_header/header.tsx +++ b/app/components/navigation_header/header.tsx @@ -204,7 +204,7 @@ const Header = ({ {title} } - {!isLargeTitle && + {!isLargeTitle && Boolean(subtitle || subtitleCompanion) && { }; }); -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(); - - return isSameDay(date, now); -} - -export function isYesterday(date: Date) { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - return isSameDay(date, yesterday); -} - const RecentDate = (props: DateSeparatorProps) => { const {date, ...otherProps} = props; const when = new Date(date); diff --git a/app/components/remove_markdown/index.tsx b/app/components/remove_markdown/index.tsx new file mode 100644 index 0000000000..395dcaf189 --- /dev/null +++ b/app/components/remove_markdown/index.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Parser} from 'commonmark'; +import Renderer from 'commonmark-react-renderer'; +import React, {ReactElement, useCallback, useRef} from 'react'; +import {StyleProp, Text, TextStyle} from 'react-native'; + +import Emoji from '@components/emoji'; + +import type {MarkdownEmojiRenderer} from '@typings/global/markdown'; + +type Props = { + enableEmoji?: boolean; + enableHardBreak?: boolean; + enableSoftBreak?: boolean; + textStyle: StyleProp; + value: string; +}; + +const RemoveMarkdown = ({enableEmoji, enableHardBreak, enableSoftBreak, textStyle, value}: Props) => { + const renderEmoji = useCallback(({emojiName, literal}: MarkdownEmojiRenderer) => { + return ( + + ); + }, [textStyle]); + + const renderBreak = useCallback(() => { + return '\n'; + }, []); + + const renderText = ({literal}: {literal: string}) => { + return {literal}; + }; + + const renderNull = () => { + return null; + }; + + const createRenderer = () => { + return new Renderer({ + renderers: { + text: renderText, + + emph: Renderer.forwardChildren, + strong: Renderer.forwardChildren, + del: Renderer.forwardChildren, + code: Renderer.forwardChildren, + link: Renderer.forwardChildren, + image: renderNull, + atMention: Renderer.forwardChildren, + channelLink: Renderer.forwardChildren, + emoji: enableEmoji ? renderEmoji : renderNull, + hashtag: Renderer.forwardChildren, + + paragraph: Renderer.forwardChildren, + heading: Renderer.forwardChildren, + codeBlock: renderNull, + blockQuote: renderNull, + + list: renderNull, + item: renderNull, + + hardBreak: enableHardBreak ? renderBreak : renderNull, + thematicBreak: renderNull, + softBreak: enableSoftBreak ? renderBreak : renderNull, + + htmlBlock: renderNull, + htmlInline: renderNull, + + table: renderNull, + table_row: renderNull, + table_cell: renderNull, + + mention_highlight: Renderer.forwardChildren, + editedIndicator: Renderer.forwardChildren, + } as any, + }); + }; + + const parser = useRef(new Parser()).current; + const renderer = useRef(createRenderer()).current; + const ast = parser.parse(value); + + return renderer.render(ast) as ReactElement; +}; + +export default RemoveMarkdown; diff --git a/app/constants/datetime.ts b/app/constants/datetime.ts new file mode 100644 index 0000000000..369e497d0b --- /dev/null +++ b/app/constants/datetime.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + SECONDS: { + MINUTE: 60, + HOUR: 3600, // 60 seconds * 60 minutes + DAY: 86400, // 24 hours = 24 * 3600 + DAYS_30: 2592000, // 30 days * 86400 seconds + DAYS_31: 2678400, // 31 days * 86400 seconds + DAYS_365: 31536000, // 365 days * 86400 seconds + DAYS_366: 31622400, // 366 days * 86400 seconds + }, +}; diff --git a/app/constants/index.ts b/app/constants/index.ts index 1f06991488..0b15c21875 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -8,6 +8,7 @@ import Channel from './channel'; import Config from './config'; import {CustomStatusDuration} from './custom_status'; import Database from './database'; +import DateTime from './datetime'; import DeepLink from './deep_linking'; import Device from './device'; import Emoji from './emoji'; @@ -37,6 +38,7 @@ export { CustomStatusDuration, Channel, Database, + DateTime, DeepLink, Device, Emoji, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index bb95172690..8c152c8a96 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -22,6 +22,7 @@ export const EDIT_SERVER = 'EditServer'; export const FIND_CHANNELS = 'FindChannels'; export const FORGOT_PASSWORD = 'ForgotPassword'; export const GALLERY = 'Gallery'; +export const GLOBAL_THREADS = 'GlobalThreads'; export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const IN_APP_NOTIFICATION = 'InAppNotification'; @@ -39,6 +40,7 @@ export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const SSO = 'SSO'; export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; +export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; export default { @@ -63,6 +65,7 @@ export default { FIND_CHANNELS, FORGOT_PASSWORD, GALLERY, + GLOBAL_THREADS, HOME, INTEGRATION_SELECTOR, IN_APP_NOTIFICATION, @@ -80,6 +83,7 @@ export default { SSO, THREAD, THREAD_FOLLOW_BUTTON, + THREAD_OPTIONS, USER_PROFILE, }; diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 37e4bff724..d45adc93be 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -5,7 +5,7 @@ import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; import {of as of$, map as map$, Observable} from 'rxjs'; import {switchMap, distinctUntilChanged, combineLatestWith} from 'rxjs/operators'; -import {Database as DatabaseConstants, Preferences} from '@constants'; +import {Database as DatabaseConstants, Preferences, Screens} from '@constants'; import {getPreferenceValue} from '@helpers/api/preference'; import {selectDefaultTeam} from '@helpers/api/team'; import {DEFAULT_LOCALE} from '@i18n'; @@ -29,13 +29,17 @@ const { TEAM_CHANNEL_HISTORY, } = DatabaseConstants.MM_TABLES.SERVER; +// Saves channels to team history & excludes & GLOBAL_THREADS from it export const addChannelToTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => { let tch: TeamChannelHistory|undefined; try { - const myChannel = (await operator.database.get(MY_CHANNEL).find(channelId)); - if (!myChannel) { - return []; + // Exlude GLOBAL_THREADS from channel check + if (channelId !== Screens.GLOBAL_THREADS) { + const myChannel = (await operator.database.get(MY_CHANNEL).find(channelId)); + if (!myChannel) { + return []; + } } const teamChannelHistory = await getTeamChannelHistory(operator.database, teamId); const channelIdSet = new Set(teamChannelHistory); @@ -49,7 +53,7 @@ export const addChannelToTeamHistory = async (operator: ServerDataOperator, team id: teamId, channel_ids: channelIds.slice(0, 5), }; - } catch { + } catch (e) { tch = { id: teamId, channel_ids: [channelId], diff --git a/app/screens/global_threads/index.tsx b/app/screens/global_threads/index.tsx new file mode 100644 index 0000000000..bc93109f48 --- /dev/null +++ b/app/screens/global_threads/index.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard, StyleSheet, View} from 'react-native'; +import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; + +import NavigationHeader from '@components/navigation_header'; +import {useAppState, useIsTablet} from '@hooks/device'; +import {useDefaultHeaderHeight} from '@hooks/header'; +import {popTopScreen} from '@screens/navigation'; + +import ThreadsList from './threads_list'; + +type Props = { + componentId?: string; +}; + +const edges: Edge[] = ['left', 'right']; + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, +}); + +const GlobalThreads = ({componentId}: Props) => { + const appState = useAppState(); + const intl = useIntl(); + const insets = useSafeAreaInsets(); + const isTablet = useIsTablet(); + + const defaultHeight = useDefaultHeaderHeight(); + + const [tab, setTab] = useState('all'); + + const containerStyle = useMemo(() => { + const marginTop = defaultHeight + insets.top; + return {flex: 1, marginTop}; + }, [defaultHeight, insets.top]); + + const onBackPress = useCallback(() => { + Keyboard.dismiss(); + popTopScreen(componentId); + }, [componentId]); + + return ( + + + + + + + ); +}; + +export default GlobalThreads; diff --git a/app/screens/global_threads/threads_list/empty_state.tsx b/app/screens/global_threads/threads_list/empty_state.tsx new file mode 100644 index 0000000000..1f9e71a9e2 --- /dev/null +++ b/app/screens/global_threads/threads_list/empty_state.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import EmptyStateIllustration from './illustrations/empty_state'; + +type Props = { + isUnreads: boolean; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + flexGrow: 1, + justifyContent: 'center', + }, + textContainer: { + padding: 32, + }, + title: { + color: theme.centerChannelColor, + fontSize: 20, + fontWeight: '600', + textAlign: 'center', + }, + subTitle: { + color: theme.centerChannelColor, + fontSize: 16, + fontWeight: '400', + lineHeight: 24, + marginTop: 16, + textAlign: 'center', + }, + }; +}); + +function EmptyState({isUnreads}: Props) { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleSheet(theme); + let title; + let subTitle; + if (isUnreads) { + title = intl.formatMessage({ + id: 'global_threads.emptyUnreads.title', + defaultMessage: 'No unread threads', + }); + subTitle = intl.formatMessage({ + id: 'global_threads.emptyUnreads.message', + defaultMessage: "Looks like you're all caught up.", + }); + } else { + title = intl.formatMessage({ + id: 'global_threads.emptyThreads.title', + defaultMessage: 'No followed threads yet', + }); + subTitle = intl.formatMessage({ + id: 'global_threads.emptyThreads.message', + defaultMessage: 'Any threads you are mentioned in or have participated in will show here along with any threads you have followed.', + }); + } + return ( + + + + + {title} + + + {subTitle} + + + + ); +} + +export default EmptyState; diff --git a/app/screens/global_threads/threads_list/end_of_list.tsx b/app/screens/global_threads/threads_list/end_of_list.tsx new file mode 100644 index 0000000000..fb077306ea --- /dev/null +++ b/app/screens/global_threads/threads_list/end_of_list.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 {useIntl} from 'react-intl'; +import {View, Text} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +// @ts-expect-error svg extension +import SearchHintSVG from './illustrations/search_hint.svg'; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + title: { + ...typography('Heading', 300), + color: theme.centerChannelColor, + }, + subtitle: { + ...typography('Body', 100), + color: theme.centerChannelColor, + }, + container: { + flex: 1, + flexDirection: 'row', + paddingTop: 16, + paddingRight: 16, + paddingLeft: 32, + }, + right: { + flexDirection: 'column', + flex: 1, + paddingLeft: 8, + }, +})); + +function EndOfList() { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyles(theme); + + const title = intl.formatMessage({ + id: 'threads.end_of_list.title', + defaultMessage: 'That\'s the end of the list!', + }); + + const subtitle = intl.formatMessage({ + id: 'threads.end_of_list.subtitle', + defaultMessage: 'If you\'re looking for older conversations, try searching instead', + }); + + return ( + + + + + + {title} + {subtitle} + + + ); +} + +export default EndOfList; diff --git a/app/screens/global_threads/threads_list/header/index.tsx b/app/screens/global_threads/threads_list/header/index.tsx new file mode 100644 index 0000000000..7cb63f1c32 --- /dev/null +++ b/app/screens/global_threads/threads_list/header/index.tsx @@ -0,0 +1,200 @@ +// 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 {Alert, TouchableOpacity, View} from 'react-native'; + +import {updateTeamThreadsAsRead} from '@actions/remote/thread'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +export type Props = { + setTab: (tab: GlobalThreadsTab) => void; + tab: GlobalThreadsTab; + teamId: string; + testID: string; + unreadsCount: number; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + alignItems: 'center', + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08), + borderBottomWidth: 1, + flexDirection: 'row', + }, + menuContainer: { + alignItems: 'center', + flexGrow: 1, + flexDirection: 'row', + paddingLeft: 12, + marginVertical: 12, + }, + menuItemContainer: { + paddingVertical: 8, + paddingHorizontal: 16, + }, + menuItemContainerSelected: { + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + }, + menuItem: { + color: changeOpacity(theme.centerChannelColor, 0.56), + alignSelf: 'center', + ...typography('Body', 200, 'SemiBold'), + }, + menuItemSelected: { + color: theme.buttonBg, + }, + unreadsDot: { + position: 'absolute', + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.sidebarTextActiveBorder, + right: -6, + top: 4, + }, + + markAllReadIconContainer: { + paddingHorizontal: 20, + }, + markAllReadIcon: { + fontSize: 28, + lineHeight: 28, + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + markAllReadIconDisabled: { + opacity: 0.5, + }, + }; +}); + +const Header = ({setTab, tab, teamId, testID, unreadsCount}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const hasUnreads = unreadsCount > 0; + const viewingUnreads = tab === 'unreads'; + + const handleMarkAllAsRead = useCallback(preventDoubleTap(() => { + Alert.alert( + intl.formatMessage({ + id: 'global_threads.markAllRead.title', + defaultMessage: 'Are you sure you want to mark all threads as read?', + }), + intl.formatMessage({ + id: 'global_threads.markAllRead.message', + defaultMessage: 'This will clear any unread status for all of your threads shown here', + }), + [{ + text: intl.formatMessage({ + id: 'global_threads.markAllRead.cancel', + defaultMessage: 'Cancel', + }), + style: 'cancel', + }, { + text: intl.formatMessage({ + id: 'global_threads.markAllRead.markRead', + defaultMessage: 'Mark read', + }), + style: 'default', + onPress: () => { + updateTeamThreadsAsRead(serverUrl, teamId); + }, + }], + ); + }), [intl, serverUrl, teamId]); + + const handleViewAllThreads = useCallback(preventDoubleTap(() => setTab('all')), []); + const handleViewUnreadThreads = useCallback(preventDoubleTap(() => setTab('unreads')), []); + + const {allThreadsContainerStyle, allThreadsStyle, unreadsContainerStyle, unreadsStyle} = useMemo(() => { + return { + allThreadsContainerStyle: [ + styles.menuItemContainer, + viewingUnreads ? undefined : styles.menuItemContainerSelected, + ], + allThreadsStyle: [ + styles.menuItem, + viewingUnreads ? undefined : styles.menuItemSelected, + ], + unreadsContainerStyle: [ + styles.menuItemContainer, + viewingUnreads ? styles.menuItemContainerSelected : undefined, + ], + unreadsStyle: [ + styles.menuItem, + viewingUnreads ? styles.menuItemSelected : undefined, + ], + }; + }, [styles, viewingUnreads]); + + const markAllStyle = useMemo(() => [ + styles.markAllReadIcon, + hasUnreads ? undefined : styles.markAllReadIconDisabled, + ], [styles, hasUnreads]); + + const testIDPrefix = `${testID}.header`; + + return ( + + + + + + + + + + + + {hasUnreads ? ( + + ) : null} + + + + + + + + + + + ); +}; + +export default Header; diff --git a/app/screens/global_threads/threads_list/illustrations/empty_state.tsx b/app/screens/global_threads/threads_list/illustrations/empty_state.tsx new file mode 100644 index 0000000000..4b3cfb6c8e --- /dev/null +++ b/app/screens/global_threads/threads_list/illustrations/empty_state.tsx @@ -0,0 +1,184 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import Svg, { + G, + Path, + Defs, + ClipPath, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +}; + +function EmptyStateIllustration({theme}: Props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default EmptyStateIllustration; diff --git a/app/screens/global_threads/threads_list/illustrations/search_hint.svg b/app/screens/global_threads/threads_list/illustrations/search_hint.svg new file mode 100644 index 0000000000..e8f3bf5df8 --- /dev/null +++ b/app/screens/global_threads/threads_list/illustrations/search_hint.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/screens/global_threads/threads_list/index.ts b/app/screens/global_threads/threads_list/index.ts new file mode 100644 index 0000000000..d3e2ef30f3 --- /dev/null +++ b/app/screens/global_threads/threads_list/index.ts @@ -0,0 +1,36 @@ +// 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 {AppStateStatus} from 'react-native'; + +import {observeCurrentTeamId} from '@queries/servers/system'; +import {queryThreadsInTeam} from '@queries/servers/thread'; +import {observeTeammateNameDisplay} from '@queries/servers/user'; + +import ThreadsList from './threads_list'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = { + tab: GlobalThreadsTab; + teamId: string; + forceQueryAfterAppState: AppStateStatus; +} & WithDatabaseArgs; + +const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({ + teamId: observeCurrentTeamId(database), +})); + +const enhanced = withObservables(['tab', 'teamId', 'forceQueryAfterAppState'], ({database, tab, teamId}: Props) => { + const getOnlyUnreads = tab !== 'all'; + + return { + unreadsCount: queryThreadsInTeam(database, teamId, true).observeCount(false), + teammateNameDisplay: observeTeammateNameDisplay(database), + threads: queryThreadsInTeam(database, teamId, getOnlyUnreads, false, true, true).observe(), + }; +}); + +export default withDatabase(withTeamId(enhanced(ThreadsList))); diff --git a/app/screens/global_threads/threads_list/thread/index.ts b/app/screens/global_threads/threads_list/thread/index.ts new file mode 100644 index 0000000000..66c285d513 --- /dev/null +++ b/app/screens/global_threads/threads_list/thread/index.ts @@ -0,0 +1,31 @@ +// 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 {observeChannel} from '@queries/servers/channel'; +import {observeUser} from '@queries/servers/user'; + +import Thread from './thread'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +const enhanced = withObservables([], ({database, thread}: WithDatabaseArgs & {thread: ThreadModel}) => { + const post = thread.post.observe(); + return { + post, + thread: thread.observe(), + channel: post.pipe( + switchMap((p) => (p?.channelId ? observeChannel(database, p.channelId) : of$(undefined))), + ), + author: post.pipe( + switchMap((u) => (u?.userId ? observeUser(database, u.userId) : of$(undefined))), + ), + }; +}); + +export default withDatabase(enhanced(Thread)); diff --git a/app/screens/global_threads/threads_list/thread/thread.tsx b/app/screens/global_threads/threads_list/thread/thread.tsx new file mode 100644 index 0000000000..0ff6d21404 --- /dev/null +++ b/app/screens/global_threads/threads_list/thread/thread.tsx @@ -0,0 +1,248 @@ +// 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 {Text, TouchableHighlight, View} from 'react-native'; + +import {fetchAndSwitchToThread} from '@actions/remote/thread'; +import FormattedText from '@components/formatted_text'; +import FriendlyDate from '@components/friendly_date'; +import RemoveMarkdown from '@components/remove_markdown'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {displayUsername} from '@utils/user'; + +import ThreadFooter from './thread_footer'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type ThreadModel from '@typings/database/models/servers/thread'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + author?: UserModel; + channel?: ChannelModel; + post?: PostModel; + teammateNameDisplay: string; + testID: string; + thread: ThreadModel; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + paddingTop: 16, + paddingRight: 16, + flex: 1, + flexDirection: 'row', + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08), + borderBottomWidth: 1, + }, + badgeContainer: { + marginTop: 3, + width: 32, + }, + postContainer: { + flex: 1, + }, + header: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + marginBottom: 9, + }, + headerInfoContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + marginRight: 12, + overflow: 'hidden', + }, + threadDeleted: { + color: changeOpacity(theme.centerChannelColor, 0.72), + fontStyle: 'italic', + }, + threadStarter: { + color: theme.centerChannelColor, + fontSize: 15, + fontWeight: '600', + lineHeight: 22, + paddingRight: 8, + }, + channelNameContainer: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: 4, + maxWidth: '50%', + }, + channelName: { + color: theme.centerChannelColor, + ...typography('Body', 25, 'SemiBold'), + letterSpacing: 0.1, + textTransform: 'uppercase', + marginHorizontal: 12, + marginVertical: 2, + }, + date: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 25, 'Light'), + }, + message: { + color: theme.centerChannelColor, + ...typography('Body', 200), + }, + unreadDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.sidebarTextActiveBorder, + alignSelf: 'center', + marginTop: 5, + }, + mentionBadge: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: theme.buttonBg, + alignSelf: 'center', + }, + mentionBadgeText: { + ...typography('Body', 25, 'SemiBold'), + alignSelf: 'center', + color: theme.buttonColor, + }, + }; +}); + +const Thread = ({author, channel, post, teammateNameDisplay, testID, thread}: Props) => { + const intl = useIntl(); + const isTablet = useIsTablet(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const serverUrl = useServerUrl(); + + const showThread = useCallback(preventDoubleTap(() => { + fetchAndSwitchToThread(serverUrl, thread.id); + }), [serverUrl, thread.id]); + + const showThreadOptions = useCallback(() => { + const passProps = {thread}; + const title = isTablet ? intl.formatMessage({id: 'thread.options.title', defaultMessage: 'THREAD ACTIONS'}) : ''; + + if (isTablet) { + showModal(Screens.THREAD_OPTIONS, title, passProps, bottomSheetModalOptions(theme, 'close-thread-options')); + } else { + showModalOverCurrentContext(Screens.THREAD_OPTIONS, passProps); + } + }, [isTablet, theme, thread]); + + const threadStarterName = displayUsername(author, intl.locale, teammateNameDisplay); + const testIDPrefix = `${testID}.${thread.id}`; + + const needBadge = thread.unreadMentions || thread.unreadReplies; + let badgeComponent; + if (needBadge) { + if (thread.unreadMentions) { + badgeComponent = ( + + {thread.unreadMentions > 99 ? '99+' : thread.unreadMentions} + + ); + } else if (thread.unreadReplies) { + badgeComponent = ( + + ); + } + } + + let name; + let postBody; + if (!post || post.deleteAt > 0) { + name = ( + + ); + } else { + name = ( + + {threadStarterName} + + ); + if (post?.message) { + postBody = ( + + + + ); + } + } + + return ( + + + + {badgeComponent} + + + + + {name} + {channel && threadStarterName !== channel?.displayName && ( + + + {channel?.displayName} + + + )} + + + + {postBody} + + + + + ); +}; + +export default Thread; diff --git a/app/screens/global_threads/threads_list/thread/thread_footer/index.ts b/app/screens/global_threads/threads_list/thread/thread_footer/index.ts new file mode 100644 index 0000000000..01a8723825 --- /dev/null +++ b/app/screens/global_threads/threads_list/thread/thread_footer/index.ts @@ -0,0 +1,20 @@ +// 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 {queryThreadParticipants} from '@queries/servers/thread'; + +import ThreadFooter from './thread_footer'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +const enhanced = withObservables([], ({database, thread}: WithDatabaseArgs & {thread: ThreadModel}) => { + return { + participants: queryThreadParticipants(database, thread.id).observe(), + }; +}); + +export default withDatabase(enhanced(ThreadFooter)); diff --git a/app/screens/global_threads/threads_list/thread/thread_footer/thread_footer.tsx b/app/screens/global_threads/threads_list/thread/thread_footer/thread_footer.tsx new file mode 100644 index 0000000000..1d7032c1a9 --- /dev/null +++ b/app/screens/global_threads/threads_list/thread/thread_footer/thread_footer.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import UserAvatarsStack from '@components/user_avatars_stack'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type ThreadModel from '@typings/database/models/servers/thread'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + author?: UserModel; + participants: UserModel[]; + testID: string; + thread: ThreadModel; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flexDirection: 'row', + alignItems: 'center', + minHeight: 40, + }, + avatarsContainer: { + marginRight: 12, + paddingVertical: 8, + }, + replies: { + alignSelf: 'center', + color: changeOpacity(theme.centerChannelColor, 0.64), + marginRight: 12, + ...typography('Body', 75, 'SemiBold'), + }, + unreadReplies: { + alignSelf: 'center', + color: theme.sidebarTextActiveBorder, + marginRight: 12, + ...typography('Body', 75, 'SemiBold'), + }, + }; +}); + +const ThreadFooter = ({author, participants, testID, thread}: Props) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + + let repliesComponent; + if (thread.unreadReplies) { + repliesComponent = ( + + ); + } else if (thread.replyCount) { + repliesComponent = ( + + ); + } + + // threadstarter should be the first one in the avatars list + const participantsList = useMemo(() => { + if (author && participants?.length) { + const filteredParticipantsList = participants.filter((participant) => participant.id !== author.id).reverse(); + filteredParticipantsList.unshift(author); + return filteredParticipantsList; + } + return []; + }, [participants, author]); + + let userAvatarsStack; + if (author && participantsList.length) { + userAvatarsStack = ( + + ); + } + + return ( + + {userAvatarsStack} + {repliesComponent} + + ); +}; + +export default ThreadFooter; diff --git a/app/screens/global_threads/threads_list/threads_list.tsx b/app/screens/global_threads/threads_list/threads_list.tsx new file mode 100644 index 0000000000..7583752e4b --- /dev/null +++ b/app/screens/global_threads/threads_list/threads_list.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState, useRef} from 'react'; +import {FlatList, StyleSheet} from 'react-native'; + +import {fetchThreads} from '@actions/remote/thread'; +import Loading from '@components/loading'; +import {General} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; + +import EmptyState from './empty_state'; +import EndOfList from './end_of_list'; +import Header from './header'; +import Thread from './thread'; + +import type ThreadModel from '@typings/database/models/servers/thread'; + +type Props = { + setTab: (tab: GlobalThreadsTab) => void; + tab: GlobalThreadsTab; + teamId: string; + teammateNameDisplay: string; + testID: string; + threads: ThreadModel[]; + unreadsCount: number; +}; + +const styles = StyleSheet.create({ + messagesContainer: { + flexGrow: 1, + }, + loadingStyle: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + paddingTop: 16, + paddingBottom: 8, + }, +}); + +const ThreadsList = ({ + setTab, + tab, + teamId, + teammateNameDisplay, + testID, + threads, + unreadsCount, +}: Props) => { + const serverUrl = useServerUrl(); + const theme = useTheme(); + + const hasFetchedOnce = useRef(false); + const [isLoading, setIsLoading] = useState(false); + const [endReached, setEndReached] = useState(false); + + const noThreads = !threads?.length; + const lastThread = threads?.length > 0 ? threads[threads.length - 1] : null; + + useEffect(() => { + // this is to be called only when there are no threads + if (tab === 'all' && noThreads && !hasFetchedOnce.current) { + setIsLoading(true); + fetchThreads(serverUrl, teamId).finally(() => { + hasFetchedOnce.current = true; + setIsLoading(false); + }); + } + }, [noThreads, tab]); + + const listEmptyComponent = useMemo(() => { + if (isLoading) { + return ( + + ); + } + return ( + + ); + }, [isLoading, theme, tab]); + + const listFooterComponent = useMemo(() => { + if (tab === 'unreads' || !threads.length) { + return null; + } + + if (endReached) { + return ( + + ); + } else if (isLoading) { + return ( + + ); + } + + return null; + }, [isLoading, tab, theme, endReached]); + + const handleEndReached = useCallback(() => { + if (!lastThread || tab === 'unreads' || endReached) { + return; + } + + const options = { + before: lastThread.id, + perPage: General.CRT_CHUNK_SIZE, + }; + + setIsLoading(true); + fetchThreads(serverUrl, teamId, options).then((response) => { + if ('data' in response) { + setEndReached(response.data.threads.length < General.CRT_CHUNK_SIZE); + } + }).finally(() => { + setIsLoading(false); + }); + }, [endReached, lastThread?.id, serverUrl, tab, teamId]); + + const renderItem = useCallback(({item}) => ( + + ), [teammateNameDisplay, testID]); + + return ( + <> +
+ + + ); +}; + +export default ThreadsList; diff --git a/app/screens/home/channel_list/additional_tablet_view/index.tsx b/app/screens/home/channel_list/additional_tablet_view/index.tsx new file mode 100644 index 0000000000..dee5a3dca8 --- /dev/null +++ b/app/screens/home/channel_list/additional_tablet_view/index.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; + +import {Navigation, Screens} from '@constants'; +import Channel from '@screens/channel'; +import GlobalThreads from '@screens/global_threads'; + +type SelectedView = { + id: string; + Component: any; +} + +const ComponentsList: Record = { + [Screens.CHANNEL]: Channel, + [Screens.GLOBAL_THREADS]: GlobalThreads, +}; + +const AdditionalTabletView = () => { + const [selected, setSelected] = useState(); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_HOME, (id: string) => { + const component = ComponentsList[id]; + if (component) { + setSelected({ + Component: component, + id, + }); + } + }); + + return () => listener.remove(); + }, []); + + if (!selected) { + return null; + } + + return React.createElement(selected.Component, {componentId: selected.id, isTablet: true}); +}; + +export default AdditionalTabletView; diff --git a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap index 4082f6b810..3c58bdcd92 100644 --- a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap +++ b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap @@ -404,73 +404,6 @@ exports[`components/categories_list should render team error 1`] = ` Find channels... - - - - - - Threads - - - - { expect(wrapper.toJSON()).toBeTruthy(); }); + it('should render channel list with thread menu', () => { + const wrapper = renderWithEverything( + , + {database}, + ); + expect(wrapper.toJSON()).toBeTruthy(); + }); + it('should render team error', async () => { await operator.handleSystem({ systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: ''}], diff --git a/app/screens/home/channel_list/categories_list/index.tsx b/app/screens/home/channel_list/categories_list/index.tsx index cec444b10d..f0159f90e9 100644 --- a/app/screens/home/channel_list/categories_list/index.tsx +++ b/app/screens/home/channel_list/categories_list/index.tsx @@ -13,7 +13,7 @@ import ChannelListHeader from './header'; import LoadChannelsError from './load_channels_error'; import LoadTeamsError from './load_teams_error'; import SearchField from './search'; -import Threads from './threads'; +import ThreadsButton from './threads_button'; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { @@ -29,15 +29,16 @@ type ChannelListProps = { channelsCount: number; currentTeamId?: string; iconPad?: boolean; + isCRTEnabled?: boolean; isTablet: boolean; teamsCount: number; -} +}; const getTabletWidth = (teamsCount: number) => { return TABLET_SIDEBAR_WIDTH - (teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0); }; -const ChannelList = ({channelsCount, currentTeamId, iconPad, isTablet, teamsCount}: ChannelListProps) => { +const CategoriesList = ({channelsCount, currentTeamId, iconPad, isCRTEnabled, isTablet, teamsCount}: ChannelListProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); const tabletWidth = useSharedValue(isTablet ? getTabletWidth(teamsCount) : 0); @@ -68,7 +69,7 @@ const ChannelList = ({channelsCount, currentTeamId, iconPad, isTablet, teamsCoun content = ( <> - + {isCRTEnabled && } @@ -86,4 +87,4 @@ const ChannelList = ({channelsCount, currentTeamId, iconPad, isTablet, teamsCoun ); }; -export default ChannelList; +export default CategoriesList; diff --git a/app/screens/home/channel_list/categories_list/threads/__snapshots__/index.test.tsx.snap b/app/screens/home/channel_list/categories_list/threads/__snapshots__/index.test.tsx.snap deleted file mode 100644 index abde3fdf35..0000000000 --- a/app/screens/home/channel_list/categories_list/threads/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,71 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Threads Component should match snapshot 1`] = ` - - - - - - Threads - - - - -`; diff --git a/app/screens/home/channel_list/categories_list/threads/index.tsx b/app/screens/home/channel_list/categories_list/threads/index.tsx deleted file mode 100644 index aeb9aa18c0..0000000000 --- a/app/screens/home/channel_list/categories_list/threads/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {StyleSheet, Text, View} from 'react-native'; - -import CompassIcon from '@components/compass_icon'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {Screens} from '@constants'; -import {useTheme} from '@context/theme'; -import {goToScreen} from '@screens/navigation'; -import {makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - container: { - display: 'flex', - flexDirection: 'row', - }, - icon: { - fontSize: 24, - lineHeight: 28, - color: theme.sidebarText, - }, - text: { - color: theme.sidebarText, - paddingLeft: 12, - }, -})); - -const textStyle = StyleSheet.create([typography('Body', 200, 'SemiBold')]); - -const ThreadsButton = () => { - const theme = useTheme(); - const styles = getStyleSheet(theme); - - /* - * @to-do: - * - Check if there are threads, else return null - * - Change to button, navigate to threads view - * - Add right-side number badge - */ - return ( - goToScreen(Screens.CHANNEL, 'Channel', {}, {topBar: {visible: false}})} - testID='channel_list.threads.button' - > - - - {'Threads'} - - - ); -}; - -export default ThreadsButton; diff --git a/app/screens/home/channel_list/categories_list/threads_button/__snapshots__/threads_button.test.tsx.snap b/app/screens/home/channel_list/categories_list/threads_button/__snapshots__/threads_button.test.tsx.snap new file mode 100644 index 0000000000..0987655bd0 --- /dev/null +++ b/app/screens/home/channel_list/categories_list/threads_button/__snapshots__/threads_button.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Threads Component should match snapshot 1`] = ` + + + + + + Threads + + + + +`; diff --git a/app/screens/home/channel_list/categories_list/threads_button/index.ts b/app/screens/home/channel_list/categories_list/threads_button/index.ts new file mode 100644 index 0000000000..912d535f04 --- /dev/null +++ b/app/screens/home/channel_list/categories_list/threads_button/index.ts @@ -0,0 +1,28 @@ +// 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 {switchMap} from 'rxjs/operators'; + +import {observeCurrentChannelId, observeCurrentTeamId} from '@queries/servers/system'; +import {observeUnreadsAndMentionsInTeam} from '@queries/servers/thread'; + +import ThreadsButton from './threads_button'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const currentTeamId = observeCurrentTeamId(database); + + return { + currentChannelId: observeCurrentChannelId(database), + unreadsAndMentions: currentTeamId.pipe( + switchMap( + (teamId) => observeUnreadsAndMentionsInTeam(database, teamId), + ), + ), + }; +}); + +export default withDatabase(enhanced(ThreadsButton)); diff --git a/app/screens/home/channel_list/categories_list/threads/index.test.tsx b/app/screens/home/channel_list/categories_list/threads_button/threads_button.test.tsx similarity index 62% rename from app/screens/home/channel_list/categories_list/threads/index.test.tsx rename to app/screens/home/channel_list/categories_list/threads_button/threads_button.test.tsx index daa5cf9fb2..25a796121f 100644 --- a/app/screens/home/channel_list/categories_list/threads/index.test.tsx +++ b/app/screens/home/channel_list/categories_list/threads_button/threads_button.test.tsx @@ -5,11 +5,17 @@ import React from 'react'; import {renderWithIntlAndTheme} from '@test/intl-test-helper'; -import Threads from './index'; +import Threads from './threads_button'; test('Threads Component should match snapshot', () => { const {toJSON} = renderWithIntlAndTheme( - , + , ); expect(toJSON()).toMatchSnapshot(); diff --git a/app/screens/home/channel_list/categories_list/threads_button/threads_button.tsx b/app/screens/home/channel_list/categories_list/threads_button/threads_button.tsx new file mode 100644 index 0000000000..c392cf01dc --- /dev/null +++ b/app/screens/home/channel_list/categories_list/threads_button/threads_button.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {TouchableOpacity, View} from 'react-native'; + +import {switchToGlobalThreads} from '@actions/local/thread'; +import Badge from '@components/badge'; +import { + getStyleSheet as getChannelItemStyleSheet, + textStyle as channelItemTextStyle, +} from '@components/channel_item/channel_item'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + baseContainer: { + marginLeft: -18, + marginRight: -20, + }, + icon: { + color: changeOpacity(theme.sidebarText, 0.5), + fontSize: 24, + }, + iconActive: { + color: theme.sidebarText, + }, +})); + +type Props = { + currentChannelId: string; + unreadsAndMentions: { + unreads: number; + mentions: number; + }; +}; + +const ThreadsButton = ({currentChannelId, unreadsAndMentions}: Props) => { + const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + + const theme = useTheme(); + const styles = getChannelItemStyleSheet(theme); + const customStyles = getStyleSheet(theme); + + const handlePress = useCallback(preventDoubleTap(() => { + switchToGlobalThreads(serverUrl); + }), [serverUrl]); + + const {unreads, mentions} = unreadsAndMentions; + const isActive = isTablet && !currentChannelId; + + const [containerStyle, iconStyle, textStyle] = useMemo(() => { + const container = [ + styles.container, + isActive ? styles.activeItem : undefined, + ]; + + const icon = [ + customStyles.icon, + isActive || unreads ? customStyles.iconActive : undefined, + ]; + + const text = [ + unreads ? channelItemTextStyle.bright : channelItemTextStyle.regular, + styles.text, + unreads ? styles.highlight : undefined, + isActive ? styles.textActive : undefined, + ]; + + return [container, icon, text]; + }, [customStyles, isActive, styles, unreads]); + + return ( + + + + + + 0} + /> + + + + ); +}; + +export default ThreadsButton; diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index d25c9e1e42..648c1ff8f5 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -12,14 +12,15 @@ import FreezeScreen from '@components/freeze_screen'; import TeamSidebar from '@components/team_sidebar'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; -import Channel from '@screens/channel'; +import AdditionalTabletView from './additional_tablet_view'; import CategoriesList from './categories_list'; import Servers from './servers'; type ChannelProps = { channelsCount: number; currentTeamId?: string; + isCRTEnabled: boolean; teamsCount: number; time?: number; }; @@ -86,13 +87,14 @@ const ChannelListScreen = (props: ChannelProps) => { /> {isTablet && Boolean(props.currentTeamId) && - + } diff --git a/app/screens/home/channel_list/index.ts b/app/screens/home/channel_list/index.ts index 5757249f91..c3a50e8d2f 100644 --- a/app/screens/home/channel_list/index.ts +++ b/app/screens/home/channel_list/index.ts @@ -7,6 +7,7 @@ import withObservables from '@nozbe/with-observables'; import {queryAllMyChannel} from '@queries/servers/channel'; import {observeCurrentTeamId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; +import {observeIsCRTEnabled} from '@queries/servers/thread'; import ChannelsList from './channel_list'; @@ -14,6 +15,7 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ currentTeamId: observeCurrentTeamId(database), + isCRTEnabled: observeIsCRTEnabled(database), teamsCount: queryMyTeams(database).observeCount(), channelsCount: queryAllMyChannel(database).observeCount(), })); diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 72a6efb161..3b469aef97 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -112,6 +112,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.GALLERY: screen = withServerDatabase(require('@screens/gallery').default); break; + case Screens.GLOBAL_THREADS: + screen = withServerDatabase(require('@screens/global_threads').default); + break; case Screens.IN_APP_NOTIFICATION: { const notificationScreen = require('@screens/in_app_notification').default; @@ -154,6 +157,11 @@ Navigation.setLazyComponentRegistrator((screenName) => { require('@screens/thread/thread_follow_button').default, )); break; + case Screens.THREAD_OPTIONS: + screen = withServerDatabase( + require('@screens/thread_options').default, + ); + break; } if (screen) { diff --git a/app/screens/thread_options/index.ts b/app/screens/thread_options/index.ts new file mode 100644 index 0000000000..d92e18629e --- /dev/null +++ b/app/screens/thread_options/index.ts @@ -0,0 +1,27 @@ +// 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 {observePostSaved} from '@queries/servers/post'; +import {observeCurrentTeam} from '@queries/servers/team'; + +import ThreadOptions from './thread_options'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +type Props = WithDatabaseArgs & { + thread: ThreadModel; +}; + +const enhanced = withObservables(['thread'], ({database, thread}: Props) => { + return { + isSaved: observePostSaved(database, thread.id), + post: thread.post.observe(), + team: observeCurrentTeam(database), + }; +}); + +export default withDatabase(enhanced(ThreadOptions)); diff --git a/app/screens/thread_options/options/mark_as_unread_option.tsx b/app/screens/thread_options/options/mark_as_unread_option.tsx new file mode 100644 index 0000000000..db8fd8c166 --- /dev/null +++ b/app/screens/thread_options/options/mark_as_unread_option.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; + +import {updateThreadRead} from '@actions/remote/thread'; +import {BaseOption} from '@components/common_post_options'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {t} from '@i18n'; +import {dismissBottomSheet} from '@screens/navigation'; + +import type PostModel from '@typings/database/models/servers/post'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +type Props = { + post: PostModel; + teamId: string; + thread: ThreadModel; +} +const MarkAsUnreadOption = ({teamId, thread, post}: Props) => { + const serverUrl = useServerUrl(); + + const onHandlePress = useCallback(async () => { + const timestamp = thread.unreadReplies ? Date.now() : post.createAt; + updateThreadRead(serverUrl, teamId, thread.id, timestamp); + dismissBottomSheet(Screens.THREAD_OPTIONS); + }, [serverUrl, thread]); + + const id = thread.unreadReplies ? t('global_threads.options.mark_as_read') : t('mobile.post_info.mark_unread'); + const defaultMessage = thread.unreadReplies ? 'Mark as Read' : 'Mark as Unread'; + + return ( + + ); +}; + +export default MarkAsUnreadOption; diff --git a/app/screens/thread_options/options/open_in_channel_option.tsx b/app/screens/thread_options/options/open_in_channel_option.tsx new file mode 100644 index 0000000000..f8d1ae0e8c --- /dev/null +++ b/app/screens/thread_options/options/open_in_channel_option.tsx @@ -0,0 +1,37 @@ +// 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 {showPermalink} from '@actions/remote/permalink'; +import {BaseOption} from '@components/common_post_options'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {t} from '@i18n'; +import {dismissBottomSheet} from '@screens/navigation'; + +type Props = { + threadId: string; +} +const OpenInChannelOption = ({threadId}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const onHandlePress = useCallback(async () => { + await dismissBottomSheet(Screens.THREAD_OPTIONS); + showPermalink(serverUrl, '', threadId, intl); + }, [intl, serverUrl, threadId]); + + return ( + + ); +}; + +export default OpenInChannelOption; diff --git a/app/screens/thread_options/thread_options.tsx b/app/screens/thread_options/thread_options.tsx new file mode 100644 index 0000000000..8725cf0533 --- /dev/null +++ b/app/screens/thread_options/thread_options.tsx @@ -0,0 +1,141 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import {Navigation} from 'react-native-navigation'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {CopyPermalinkOption, FollowThreadOption, ReplyOption, SaveOption} from '@components/common_post_options'; +import FormattedText from '@components/formatted_text'; +import {ITEM_HEIGHT} from '@components/menu_item'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import BottomSheet from '@screens/bottom_sheet'; +import {dismissModal} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import MarkAsUnreadOption from './options/mark_as_unread_option'; +import OpenInChannelOption from './options/open_in_channel_option'; + +import type PostModel from '@typings/database/models/servers/post'; +import type TeamModel from '@typings/database/models/servers/team'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +type ThreadOptionsProps = { + componentId: string; + isSaved: boolean; + post: PostModel; + team: TeamModel; + thread: ThreadModel; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + listHeader: { + marginBottom: 12, + }, + listHeaderText: { + color: changeOpacity(theme.centerChannelColor, 0.56), + ...typography('Body', 75, 'SemiBold'), + }, + }; +}); + +const ThreadOptions = ({ + componentId, + isSaved, + post, + team, + thread, +}: ThreadOptionsProps) => { + const theme = useTheme(); + const isTablet = useIsTablet(); + + const insets = useSafeAreaInsets(); + + const style = getStyleSheet(theme); + + useEffect(() => { + const unsubscribe = Navigation.events().registerComponentListener({ + navigationButtonPressed: ({buttonId}: { buttonId: string }) => { + if (buttonId === 'close-thread-options') { + dismissModal({componentId}); + } + }, + }, componentId); + + return () => { + unsubscribe.remove(); + }; + }, []); + + const options = [ + , + , + , + , + , + ]; + + const managedConfig = useManagedConfig(); + const canCopyLink = managedConfig?.copyAndPasteProtection !== 'true'; + if (canCopyLink) { + options.push( + , + ); + } + + const renderContent = () => ( + <> + {!isTablet && ( + + + + )} + {options} + + ); + + return ( + + ); +}; + +export default ThreadOptions; diff --git a/app/utils/datetime.ts b/app/utils/datetime.ts new file mode 100644 index 0000000000..104560a603 --- /dev/null +++ b/app/utils/datetime.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function isSameDate(a: Date, b: Date = new Date()): boolean { + return a.getDate() === b.getDate() && isSameMonth(a, b) && isSameYear(a, b); +} + +export function isSameMonth(a: Date, b: Date = new Date()): boolean { + return a.getMonth() === b.getMonth() && isSameYear(a, b); +} + +export function isSameYear(a: Date, b: Date = new Date()): boolean { + return a.getFullYear() === b.getFullYear(); +} + +export function isToday(date: Date) { + const now = new Date(); + + return isSameDate(date, now); +} + +export function isYesterday(date: Date): boolean { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + return isSameDate(date, yesterday); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 511ca92b44..fe1e93e061 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -173,6 +173,13 @@ "find_channels.new_channel": "New Channel", "find_channels.open_dm": "Open a DM", "find_channels.title": "Find Channels", + "friendly_date.daysAgo": "{count} {count, plural, one {day} other {days}} ago", + "friendly_date.hoursAgo": "{count} {count, plural, one {hour} other {hours}} ago", + "friendly_date.minsAgo": "{count} {count, plural, one {min} other {mins}} ago", + "friendly_date.monthsAgo": "{count} {count, plural, one {month} other {months}} ago", + "friendly_date.now": "Now", + "friendly_date.yearsAgo": "{count} {count, plural, one {year} other {years}} ago", + "friendly_date.yesterday": "Yesterday", "gallery.copy_link.failed": "Failed to copy link to clipboard", "gallery.downloading": "Downloading...", "gallery.footer.channel_name": "Shared in {channelName}", @@ -184,6 +191,20 @@ "gallery.unsupported": "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.", "gallery.video_saved": "Video saved", "get_post_link_modal.title": "Copy Link", + "global_threads.allThreads": "All Your Threads", + "global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.", + "global_threads.emptyThreads.title": "No followed threads yet", + "global_threads.emptyUnreads.message": "Looks like you're all caught up.", + "global_threads.emptyUnreads.title": "No unread threads", + "global_threads.markAllRead.cancel": "Cancel", + "global_threads.markAllRead.markRead": "Mark read", + "global_threads.markAllRead.message": "This will clear any unread status for all of your threads shown here", + "global_threads.markAllRead.title": "Are you sure you want to mark all threads as read?", + "global_threads.options.mark_as_read": "Mark as Read", + "global_threads.options.open_in_channel": "Open in Channel", + "global_threads.options.title": "THREAD ACTIONS", + "global_threads.options.unfollow": "Unfollow Thread", + "global_threads.unreads": "Unread Threads", "home.header.plus_menu": "Options", "intro.add_people": "Add People", "intro.channel_details": "Details", @@ -522,10 +543,15 @@ "thread.header.thread_in": "in {channelName}", "thread.noReplies": "No replies yet", "thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}", + "threads": "Threads", + "threads.deleted": "Original Message Deleted", + "threads.end_of_list.subtitle": "If you're looking for older conversations, try searching instead", + "threads.end_of_list.title": "That's the end of the list!", "threads.follow": "Follow", "threads.following": "Following", "threads.followMessage": "Follow Message", "threads.followThread": "Follow Thread", + "threads.newReplies": "{count} new {count, plural, one {reply} other {replies}}", "threads.replies": "{count} {count, plural, one {reply} other {replies}}", "threads.unfollowMessage": "Unfollow Message", "threads.unfollowThread": "Unfollow Thread", diff --git a/types/crt/index.d.ts b/types/crt/index.d.ts new file mode 100644 index 0000000000..5bf63fba71 --- /dev/null +++ b/types/crt/index.d.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type GlobalThreadsTab = 'all' | 'unreads';